Compare commits

..

26 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adds benchmarks + a boundary test as regression guards.

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

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

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

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

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

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

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

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

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

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

View File

@@ -2,28 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.60] - 2026-06-29
### Features
- **affordance**: Per-command usage guidance system with markdown source (#1565)
- **event**: Support VC meeting lifecycle events (#1632)
- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606)
- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598)
- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647)
### Bug Fixes
- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652)
### Tests
- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428)
### Build
- **ci**: Reduce public content false positives
## [v1.0.59] - 2026-06-26
### Features
@@ -1299,7 +1277,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60
[v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59
[v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57

View File

@@ -1,49 +0,0 @@
# Affordance
Per-command usage guidance for the CLI, authored as one markdown file per domain
(`<service>.md`). It is surfaced in `lark-cli <command> --help` and in the
`schema` output, and read directly at runtime (lazy, cached) — there is no build
step. Maintain these files alongside `skills/` and `shortcuts/`.
## Format
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
## Example
## messages get
Fetch the full content of a single message by id.
### Avoid when
- Reading several at once → use [[messages batch_get]]
### Prerequisites
- message_id from [[messages list]]
### Examples
**Fetch one message**
```bash
lark-cli mail user_mailbox.messages get --message-id "<id>"
```
## Notes
- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.

View File

@@ -1,19 +0,0 @@
# contact
> skill: lark-contact
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.
### Avoid when
- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]]
### Tips
- Off by default — set include_personal_status / include_description to true under query_option
- ids in user_ids must match --user-id-type (default open_id)
### Examples
**Bulk-query status and signature**
```bash
lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}'
```

View File

@@ -67,21 +67,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)",
Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path.
Prefer the typed domain command when one exists — it validates parameters,
shows the Risk level, gates destructive calls behind --yes, and carries usage
guidance that this raw command does not. If a domain command covers your task
(browse with ` + "`lark-cli <domain> --help`" + `), use it instead of this.
Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g.
newer/preview APIs), where you already have the HTTP path from the Lark docs.
Examples:
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`,
Args: cobra.ExactArgs(2),
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]

View File

@@ -170,10 +170,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.SetOut(cfg.streams.Out)
rootCmd.SetErr(cfg.streams.ErrOut)
// Root-only usage template (curated Usage synopsis + skills footer); see
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
@@ -209,8 +205,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {

View File

@@ -10,22 +10,10 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
_ "github.com/larksuite/cli/events"
)
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := eventlib.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) should succeed", key)
}
}
}
func TestRunList_TextOutput(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -39,8 +27,6 @@ func TestRunList_TextOutput(t *testing.T) {
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -71,15 +57,9 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
gotKeys := map[string]map[string]interface{}{}
for _, row := range rows {
if key, ok := row["key"].(string); ok {
gotKeys[key] = row
}
}
var foundTask bool
for key, row := range gotKeys {
if key == "task.task.update_user_access_v2" {
for _, row := range rows {
if row["key"] == "task.task.update_user_access_v2" {
foundTask = true
if row["single_consumer"] != true {
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
@@ -89,12 +69,4 @@ func TestRunList_JSONOutput(t *testing.T) {
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
if _, ok := gotKeys[want]; !ok {
t.Errorf("JSON list output missing %q", want)
}
}
}

View File

@@ -124,45 +124,6 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
t.Run(key, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload["key"] != key {
t.Errorf("key = %v, want %s", payload["key"], key)
}
resolved, ok := payload["resolved_output_schema"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload)
}
properties, ok := resolved["properties"].(map[string]interface{})
if !ok {
t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved)
}
for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} {
if _, ok := properties[field]; !ok {
t.Errorf("resolved output schema missing field %q: %+v", field, properties)
}
}
if _, ok := properties["end_time"]; ok {
t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties)
}
})
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

View File

@@ -11,11 +11,9 @@ import (
"sort"
"strings"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/deprecation"
@@ -30,60 +28,43 @@ import (
const rootLong = `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
# List calendar events
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
# Search users
lark-cli contact +search-user --query "John"
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Install all skills:
npx skills add larksuite/cli -g -y
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Learn more: https://github.com/larksuite/cli#agent-skills
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
@@ -548,49 +529,6 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
return available, deprecated
}
// Root command help groups, so an agent sees content domains, agent tooling, and
// CLI management as distinct blocks instead of one flat alphabetical dump.
const (
groupDomains = "lark-domains"
groupTooling = "agent-tooling"
groupManagement = "cli-management"
)
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
if c.GroupID != "" {
continue
}
switch {
case tooling[c.Name()]:
c.GroupID = groupTooling
case management[c.Name()]:
c.GroupID = groupManagement
case isLarkDomain(c):
c.GroupID = groupDomains
}
}
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService {
return true
}
return cmdmeta.Domain(c) != ""
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into a typed validation envelope: an
// unknown flag gets a focused "did you mean" hint (so agents recover even when
@@ -672,17 +610,6 @@ func installTipsHelpFunc(root *cobra.Command) {
defer func() { f.Hidden = true }()
}
}
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
if level, ok := cmdutil.GetRisk(cmd); ok {

View File

@@ -76,13 +76,11 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
// The human skills-install guidance now lives in the root usage-template
// footer (below the command list), not in the agent-facing Long.
if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate)
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate)
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}

View File

@@ -4,211 +4,41 @@
package service
import (
"encoding/json"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
// PrepareDomainHelp appends navigational guidance (routing line, risk legend,
// skill pointer) to a top-level Lark domain's description, returning false for
// anything that is not such a domain. Built lazily at help time because
// shortcuts attach after service registration. skillFS (nil-safe) gates the
// skill pointer.
//
// A hand-authored Long is preserved as the base (e.g. event's "Use 'event
// consume <EventKey>'…"); service domains carry only a Short at this point, so
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
// Direct child of root only — so Domain() reads this command's own tag, and
// nested resource groups are excluded.
if cmd.Parent() == nil || cmd.Parent().Parent() != nil {
return false
}
// A domain is service-sourced or shortcut-tagged; CLI tooling has neither.
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" {
return false
}
if !cmd.HasAvailableSubCommands() {
return false
}
hasShortcuts, hasResources := false, false
for _, c := range cmd.Commands() {
if c.Hidden || c.Name() == "help" || c.Name() == "completion" {
continue
}
if strings.HasPrefix(c.Name(), "+") {
hasShortcuts = true
} else {
hasResources = true
}
}
var b strings.Builder
b.WriteString(domainHelpBase(cmd))
if hasShortcuts && hasResources { // routing only matters when both styles exist
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
cmd.Long = b.String()
return true
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
if base == "" {
base = cmd.Short
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[domainBaseAnnotation] = base
return base
}
// methodLong is the build-time Long (description + schema pointer +
// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp,
// so command construction never parses the overlay.
func methodLong(description, schemaPath, paramsOnly string) string {
// methodLong composes a method command's long help in one place: the
// description, the affordance guidance block (when the method has one), the
// pointer to the full schema, and the params-only addendum (params whose flag
// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance
// sits near the top so an agent sees when-to-use and few-shot examples before
// the flag list.
func methodLong(description, affordance, schemaPath, paramsOnly string) string {
var b strings.Builder
b.WriteString(description)
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
if affordance != "" {
b.WriteString("\n\n")
b.WriteString(affordance)
}
fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath)
b.WriteString(paramsOnly)
return b.String()
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
const (
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
// few strings is the only build-time cost; the overlay stays untouched).
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
}
}
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
}
schemaPath, ok := ann[schemaPathAnnotation]
if !ok {
return false
}
var b strings.Builder
b.WriteString(cmd.Short)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
skills = a.Skills
}
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For
// RenderAffordanceForCmd renders a method command's affordance block, or "" when
// it carries none.
func RenderAffordanceForCmd(cmd *cobra.Command) string {
raw, ok := affordanceRaw(cmd)
if !ok {
return ""
}
return renderAffordance(meta.Method{Affordance: raw})
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
}
// renderAffordance renders a method's affordance as a help block, or "" when it
// has none. Sections are joined with blank lines so they scan as distinct groups.
// renderAffordance renders a method's affordance as a help block — when to use,
// prerequisites, and (most importantly for agents) few-shot Examples — or "" when
// the method carries no affordance. It reads the single typed model
// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape.
func renderAffordance(m meta.Method) string {
a, ok := m.ParsedAffordance()
if !ok {
return ""
}
var sections []string
var b strings.Builder
bullets := func(title string, items []string) {
var nonEmpty []string
for _, it := range items {
@@ -219,18 +49,15 @@ func renderAffordance(m meta.Method) string {
if len(nonEmpty) == 0 {
return
}
var s strings.Builder
fmt.Fprintf(&s, "%s:\n", title)
fmt.Fprintf(&b, "%s:\n", title)
for _, it := range nonEmpty {
fmt.Fprintf(&s, " • %s\n", it)
fmt.Fprintf(&b, " • %s\n", it)
}
sections = append(sections, strings.TrimRight(s.String(), "\n"))
}
bullets("When to use", a.UseWhen)
bullets("Avoid when", a.AvoidWhen)
bullets("Avoid when", a.DoNotUseWhen)
bullets("Prerequisites", a.Prerequisites)
bullets("Tips", a.Tips)
if len(a.Examples) > 0 {
var lines []string
for _, ex := range a.Examples {
@@ -244,13 +71,10 @@ func renderAffordance(m meta.Method) string {
}
}
if len(lines) > 0 {
sections = append(sections, "Examples:\n"+strings.Join(lines, "\n"))
fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n"))
}
}
for _, ext := range a.Extensions {
bullets(ext.Label, ext.Items)
}
bullets("Related", a.Related)
return strings.Join(sections, "\n\n")
return strings.TrimRight(b.String(), "\n")
}

View File

@@ -8,18 +8,15 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/spf13/cobra"
)
func TestRenderAffordance(t *testing.T) {
raw := json.RawMessage(`{
"use_when": ["发送文本消息"],
"avoid_when": ["群已解散"],
"do_not_use_when": ["群已解散"],
"prerequisites": ["已获取 chat_id"],
"tips": ["富文本用 msg_type=post"],
"examples": [
{"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"},
{"command":"lark-cli im messages list"},
@@ -32,7 +29,6 @@ func TestRenderAffordance(t *testing.T) {
"When to use:", "发送文本消息",
"Avoid when:", "群已解散",
"Prerequisites:", "已获取 chat_id",
"Tips:", "富文本用 msg_type=post",
"Examples:", "发一条文本", "lark-cli im messages create --params '{...}'",
"lark-cli im messages list", // example with no description -> bare command line
"Related:", "im.messages.list",
@@ -52,12 +48,9 @@ func TestRenderAffordance(t *testing.T) {
}
}
// Affordance is rendered lazily (at --help time) rather than baked into the
// command's Long, so building a command never carries the affordance block —
// even for a method whose metadata happens to declare one.
func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
func TestServiceMethod_AffordanceInLong(t *testing.T) {
withAff := map[string]interface{}{
"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息",
"path": "messages", "httpMethod": "POST", "description": "发送消息",
"affordance": map[string]interface{}{
"examples": []interface{}{
map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."},
@@ -66,120 +59,14 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil)
if strings.Contains(cmd.Long, "Examples:") {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") {
t.Errorf("affordance examples not in command Long:\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
// RenderAffordanceForCmd resolves a command's overlay through the (injectable)
// lookup and renders it; commands without a ref render nothing.
func TestRenderAffordanceForCmd(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service != "im" || methodID != "messages.create" {
return nil, false
}
return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil)
block := RenderAffordanceForCmd(cmd)
for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} {
if !strings.Contains(block, want) {
t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block)
}
}
// No overlay for this method id -> empty block.
noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil)
if got := RenderAffordanceForCmd(cmd2); got != "" {
t.Errorf("method with no overlay should render nothing, got:\n%s", got)
}
}
// PrepareMethodHelp composes the guidance into Long at the top: description,
// then the affordance block, then the full-schema pointer — so an agent reads
// when-to-use/examples before the flag list.
func TestPrepareMethodHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
// Description leads; affordance block sits above the schema pointer.
descAt := strings.Index(long, "发送消息")
useAt := strings.Index(long, "When to use:")
exAt := strings.Index(long, "Examples:")
schemaAt := strings.Index(long, "Full parameter schema:")
if descAt != 0 {
t.Errorf("description should lead Long, got:\n%s", long)
}
if !(descAt < useAt && useAt < exAt && exAt < schemaAt) {
t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long)
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
root := &cobra.Command{Use: "root"}
dom := &cobra.Command{Use: "event", Short: short, Long: long}
cmdmeta.SetDomain(dom, "event")
dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}})
root.AddCommand(dom)
return dom
}
func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
const long = "Unified event consumption system. Use 'event consume <EventKey>'."
dom := domainCmd("Consume and manage real-time events", long)
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, long) {
t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long)
}
if !strings.Contains(dom.Long, "Risk levels") {
t.Errorf("domain guidance should be appended; got:\n%s", dom.Long)
}
// Re-rendering must not append the guidance a second time.
PrepareDomainHelp(dom, nil)
if n := strings.Count(dom.Long, "Risk levels"); n != 1 {
t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long)
}
}
// A service domain carries only a Short at help time; it seeds the base.
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.HasPrefix(dom.Long, "Message and group chat management") {
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
// A method with no affordance adds no guidance block.
plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"}
cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil)
if strings.Contains(cmd2.Long, "Examples:") {
t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long)
}
}

View File

@@ -60,11 +60,8 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) {
if i := idx("--chat-id"); i < iParams || i > iBody {
t.Errorf("--chat-id not under API Parameters:\n%s", out)
}
// The redundant "<name>, required|optional." prefix is gone: required-ness is
// carried by the Required:/Optional: subheadings, and the snake-case --params
// key by the schema envelope — so it isn't echoed on every flag line.
if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") {
t.Errorf("redundant <name>, required/optional prefix should not appear:\n%s", out)
if !strings.Contains(out, "chat_id, required") {
t.Errorf("typed flag help format wrong:\n%s", out)
}
if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") {
t.Errorf("expected compact enum value=meaning inline:\n%s", out)

View File

@@ -30,11 +30,6 @@ func fieldFacts(f meta.Field) []string {
if d := sanitizeFieldDesc(f.Description); d != "" {
facts = append(facts, d)
}
if f.CanonicalType() == "boolean" {
// cobra shows no type word for bools and swallows a separate value as a
// positional, so spell out the presence-only contract.
facts = append(facts, "bool flag (presence = true; omit for false; takes no value)")
}
if opts := f.EnumOptions(); len(opts) > 0 {
facts = append(facts, "enum: "+formatEnumInline(opts))
}
@@ -47,15 +42,20 @@ func fieldFacts(f meta.Field) []string {
return facts
}
// paramFlagUsage renders the typed param flag's help line: the field's facts
// joined inline. Required/optional is not repeated here — the grouped help's
// Required:/Optional: subheadings already partition the flags — and the
// snake-case --params key is carried by the schema envelope (each param's
// property + "flag") and the params-only addendum, so it isn't echoed on every
// line either. Returns "" when the field has no facts (cobra then shows the bare
// flag with its type).
// paramFlagUsage renders the typed param flag's help line:
//
// <param_name>, required|optional[. <fact>]...
//
// It leads with the canonical underscore param name (the key this flag
// overrides in --params) and required/optional, then joins the field's facts
// inline.
func paramFlagUsage(f meta.Field) string {
return strings.Join(fieldFacts(f), ". ")
req := "optional"
if f.Required {
req = "required"
}
parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...)
return strings.Join(parts, ". ") + "."
}
// paramExample picks a concrete sample for a params-only field's --help snippet:
@@ -103,23 +103,8 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r",
// sanitizeFieldDesc is the field-description policy: one line per field, so
// keep full sentences and cut only at note separators (meta_data appends
// bullet notes after ;/) — the later sentence often carries the key
// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc
// cross-reference is dropped first (see cutDocRef).
func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";\n\r", 60) }
// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…).
// On the compact flag line the markdown link's URL is stripped, so the
// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator
// so a subject that runs straight into the phrase isn't orphaned.
var docRefRe = regexp.MustCompile(`[。;;,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`)
// cutDocRef truncates s at the first doc-reference breadcrumb.
func cutDocRef(s string) string {
if loc := docRefRe.FindStringIndex(s); loc != nil {
return s[:loc[0]]
}
return s
}
// affordance, e.g. user_mailbox_id's `可以输入"me"`.
func sanitizeFieldDesc(s string) string { return inlineClause(s, ";\n\r", 60) }
// formatEnumInline renders allowed values for the help line: "v=meaning" when
// the value carries a (sanitized, truncated) description — so opaque numeric

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -65,38 +64,15 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc
// resource-command chain — one level for a flat dotted resource like
// "chat.members", deeper for genuinely nested resources. A service with no
// methods keeps its bare command (svcCmd is created above regardless).
refs := apicatalog.ServiceMethods(svc, nil)
// Collect each resource's verbs up front so resourceShort can summarize a
// resource as its verb list from the first ensureChildCommand call.
verbs := map[string][]string{}
for _, ref := range refs {
key := strings.Join(ref.ResourcePath, ".")
verbs[key] = append(verbs[key], ref.Method.Name)
}
for _, ref := range refs {
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
resCmd := svcCmd
var path []string
for _, seg := range ref.ResourcePath {
path = append(path, seg)
resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")]))
resCmd = ensureChildCommand(resCmd, seg, seg+" operations")
}
resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags()))
}
}
// resourceShort summarizes a resource as its sorted verb list, or the
// "<name> operations" placeholder for an intermediate group with no methods.
func resourceShort(seg string, verbs []string) string {
if len(verbs) == 0 {
return seg + " operations"
}
sorted := append([]string(nil), verbs...)
sort.Strings(sorted)
return strings.Join(sorted, ", ")
}
// serviceShort is the service command's help summary: the localized description
// from the registry, falling back to the metadata's own description.
func serviceShort(svc meta.Service) string {
@@ -201,19 +177,7 @@ type methodCommandSpec struct {
// the API declares a body.
acceptsBody bool
declaresBody bool
paginates bool // method accepts a page_token param (so --page-all is meaningful)
serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup
}
// methodPaginates reports whether a method takes a page_token param, the signal
// that makes the --page-all/--page-limit/--page-delay flags meaningful.
func methodPaginates(m meta.Method) bool {
for _, f := range m.Params() {
if f.Name == "page_token" {
return true
}
}
return false
affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none
}
func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
@@ -222,7 +186,6 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
method: m,
schemaPath: ref.SchemaPath(),
servicePath: ref.Service.ServicePath,
serviceName: ref.Service.Name,
risk: m.Risk,
restricts: m.RestrictsIdentity(),
identities: m.Identities(),
@@ -230,7 +193,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec {
fileFields: detectFileFields(m),
acceptsBody: methodTakesBody(m.HTTPMethod),
declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0,
paginates: methodPaginates(m),
affordance: renderAffordance(m),
}
}
@@ -291,14 +254,6 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
// Keep the pagination flags registered (a harmless no-op if passed) but hide
// them from help on non-paginating commands, so help doesn't imply a
// get/write can paginate.
if !spec.paginates {
for _, name := range []string{"page-all", "page-limit", "page-delay"} {
_ = cmd.Flags().MarkHidden(name)
}
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
@@ -316,11 +271,10 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
// Registered last so the collision guard sees the standard flags above.
opts.binder = newParamFlagBinder(cmd, spec.params, reserved)
// Build-time Long; the agent guidance is added lazily by PrepareMethodHelp
// (setMethodHelpData records the coordinates it needs).
paramsOnly := opts.binder.paramsOnlyHelp()
cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly)
setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly)
// Single composition point for Long: description, affordance, schema
// pointer, and the binder's params-only addendum (params whose flag name is
// taken, reachable via --params only).
cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp())
// Group flags for the grouped --help renderer (typed param flags are grouped
// as API Parameters by the binder). tagFlagGroup is a no-op for flags not
@@ -338,11 +292,13 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm
tagFlagGroup(cmd.Flags(), "file", groupBody)
if fl := cmd.Flags().Lookup("params"); fl != nil {
annotate(fl, flagGroupAnnotation, []string{groupRaw})
// Keep the precedence rule on the flag's own one line (not a multi-line
// note that breaks the one-entry-per-flag rhythm an agent parses). Only
// meaningful when typed flags exist to override.
// State the precedence rule where the agent reads it: --params is the
// base, typed flags override. Only meaningful when typed flags exist.
if len(spec.params) > 0 {
fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params."
annotate(fl, flagNoteAnnotation, []string{
"Typed API parameter flags above are preferred.",
"If both are set, typed flags override matching keys in --params.",
})
}
}
for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} {

View File

@@ -1,41 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's
// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md).
// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist —
// a new content type is omitted until added to the embed list. The embed must live
// in this root package because go:embed cannot reach up out of a package's dir.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md
var embeddedContentFS embed.FS
// init wires the embedded content into the CLI. It compiles into `go build .` but
// not the single-file preview build (`go build ./main.go`), so that build stays
// self-contained (shipping no embedded content). Assembly failures warn on stderr
// rather than panicking — embedded content is nice-to-have, not load-bearing.
func init() {
if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
} else {
cmd.SetEmbeddedSkillContent(sub)
}
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
affordance.SetSource(sub)
}
}

View File

@@ -1,62 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1.
type VCParticipantMeetingJoinedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"`
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"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `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
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingJoinedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -1,281 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
eventType string
schemaType reflect.Type
}{
{eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
{eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
} {
t.Run(tc.eventType, func(t *testing.T) {
def, ok := event.Lookup(tc.eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", tc.eventType)
}
if def.Schema.Custom == nil {
t.Error("Processed key must set Schema.Custom")
}
if def.Schema.Native != nil {
t.Error("Processed key must not set Schema.Native")
}
if def.Process == nil {
t.Error("Process must not be nil for processed key")
}
if def.PreConsume == nil {
t.Error("PreConsume must not be nil for processed key")
}
if len(def.Scopes) != 1 || def.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Errorf("Scopes = %v", def.Scopes)
}
if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v", def.AuthTypes)
}
if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType {
t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents)
}
if def.Schema.Custom.Type != tc.schemaType {
t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType)
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{
name: "started",
eventType: eventTypeMeetingStarted,
process: processVCParticipantMeetingStarted,
},
{
name: "joined",
eventType: eventTypeMeetingJoined,
process: processVCParticipantMeetingJoined,
},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_001",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989000",
"app_id": "cli_test"
},
"event": {
"meeting": {
"id": "6911188411934433028",
"topic": "my meeting",
"meeting_no": "235812466",
"start_time": "1608883322",
"end_time": "1608883899",
"calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0"
}
}
}`
out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload)
if out["type"] != tc.eventType {
t.Errorf("type = %q", out["type"])
}
if out["event_id"] != "ev_vc_lifecycle_001" {
t.Errorf("event_id = %q", out["event_id"])
}
if out["timestamp"] != "1608725989000" {
t.Errorf("timestamp = %q", out["timestamp"])
}
if out["meeting_id"] != "6911188411934433028" {
t.Errorf("meeting_id = %q", out["meeting_id"])
}
if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" {
t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"])
}
if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" {
t.Errorf("calendar_event_id = %q", out["calendar_event_id"])
}
if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want {
t.Errorf("start_time = %q, want %q", out["start_time"], want)
}
if _, hasEndTime := out["end_time"]; hasEndTime {
t.Error("end_time should not be present in started/joined output")
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
t.Run(tc.name, func(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_vc_lifecycle_002",
"event_type": "` + tc.eventType + `",
"create_time": "1608725989001"
},
"event": {
"meeting": {
"id": "meeting_invalid_time",
"start_time": "bad",
"end_time": ""
}
}
}`
out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload)
switch tc.eventType {
case eventTypeMeetingStarted:
var started VCParticipantMeetingStartedOutput
if err := json.Unmarshal(out, &started); err != nil {
t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out))
}
if started.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", started.StartTime)
}
case eventTypeMeetingJoined:
var joined VCParticipantMeetingJoinedOutput
if err := json.Unmarshal(out, &joined); err != nil {
t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out))
}
if joined.StartTime != "" {
t.Errorf("StartTime = %q, want empty string", joined.StartTime)
}
}
})
}
}
func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted},
{"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined},
} {
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 TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} {
t.Run(eventType, func(t *testing.T) {
def, ok := event.Lookup(eventType)
if !ok {
t.Fatalf("%s should be registered via Keys()", eventType)
}
type call struct {
method string
path string
body any
}
var calls []call
rt := &stubAPIClient{
callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) {
calls = append(calls, call{method: method, path: path, body: body})
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
},
}
cleanup, err := def.PreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("PreConsume error: %v", err)
}
if cleanup == nil {
t.Fatal("cleanup must not be nil")
}
if len(calls) != 1 {
t.Fatalf("calls after subscribe = %d, want 1", len(calls))
}
if calls[0].method != "POST" || calls[0].path != pathMeetingSubscribe {
t.Fatalf("subscribe call = %+v", calls[0])
}
assertSubscriptionRequest(t, calls[0].body, eventType)
cleanup()
if len(calls) != 2 {
t.Fatalf("calls after cleanup = %d, want 2", len(calls))
}
if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe {
t.Fatalf("unsubscribe call = %+v", calls[1])
}
assertSubscriptionRequest(t, calls[1].body, eventType)
})
}
}
func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string {
t.Helper()
got := runMeetingLifecycleRaw(t, eventType, process, payload)
if got == nil {
t.Fatal("Process output is nil")
}
var out map[string]string
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got))
}
return out
}
func runMeetingLifecycleRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage {
t.Helper()
raw := &event.RawEvent{
EventType: eventType,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
return got
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/event"
)
// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1.
type VCParticipantMeetingStartedOutput struct {
Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"`
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"`
MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"`
Topic string `json:"topic,omitempty" desc:"Meeting topic"`
MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"`
StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"`
CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"`
}
func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
Meeting struct {
ID string `json:"id"`
Topic string `json:"topic"`
MeetingNo string `json:"meeting_no"`
StartTime string `json:"start_time"`
CalendarEventID string `json:"calendar_event_id"`
} `json:"meeting"`
} `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
}
meeting := envelope.Event.Meeting
out := &VCParticipantMeetingStartedOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
MeetingID: meeting.ID,
Topic: meeting.Topic,
MeetingNo: meeting.MeetingNo,
StartTime: unixSecondsToLocalRFC3339(meeting.StartTime),
CalendarEventID: meeting.CalendarEventID,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -11,8 +11,6 @@ import (
)
const (
eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1"
eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1"
eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1"
eventTypeNoteGenerated = "vc.note.generated_v1"
eventTypeRecordingStarted = "vc.recording.recording_started_v1"
@@ -32,38 +30,6 @@ const (
// Keys returns all VC-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeMeetingStarted,
DisplayName: "Participant meeting started",
Description: "Triggered when a meeting the current user participates in has started",
EventType: eventTypeMeetingStarted,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})},
},
Process: processVCParticipantMeetingStarted,
PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingStarted},
},
{
Key: eventTypeMeetingJoined,
DisplayName: "Participant meeting joined",
Description: "Triggered when the current user joins a meeting",
EventType: eventTypeMeetingJoined,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})},
},
Process: processVCParticipantMeetingJoined,
PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe),
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeMeetingJoined},
},
{
Key: eventTypeMeetingEnded,
DisplayName: "Participant meeting ended",

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package affordance is the lazily-loaded store of usage guidance for
// service-API methods. The source of truth is one markdown file per service in
// the top-level affordance/ tree (see mdparse.go), injected via SetSource so
// domain owners maintain it next to skills/ and shortcuts/. A service is read
// and parsed at most once, on first access, so normal command execution never
// touches it.
package affordance
import (
"encoding/json"
"io/fs"
"strings"
"sync"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/registry"
)
var (
mu sync.Mutex
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
// For returns the raw affordance overlay for one method, loading the owning
// service on first access. ok is false when there is no entry (absent source,
// parse failure, or unknown method all collapse to "no guidance").
func For(service, methodID string) (json.RawMessage, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return nil
}
m := map[string]json.RawMessage{}
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list") via the registry's
// authoritative resource↔id table. Resource names are irregularly pluralised
// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the
// space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string {
byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
}
break
}
return func(h string) string {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -1,86 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"encoding/json"
"testing"
"testing/fstest"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
// paragraph (use_when) and a fenced example.
const fixtureMD = "# approval\n" +
"> skill: lark-approval\n\n" +
"## instances cc\n" +
"把一个审批实例抄送给指定用户。\n\n" +
"### Examples\n\n" +
"**抄送给用户**\n" +
"```bash\n" +
"lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" +
"```\n\n" +
"## instances get\n" +
"查询某审批实例详情。\n\n" +
"### Examples\n\n" +
"**按 code 查询**\n" +
"```bash\n" +
"lark-cli approval instances get --instance-code \"x\"\n" +
"```\n"
func TestFor(t *testing.T) {
prev := mdSource
t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation
SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}})
// A seeded method in a seeded service resolves to its overlay.
raw, ok := For("approval", "instances.cc")
if !ok {
t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`)
}
var a struct {
UseWhen []string `json:"use_when"`
Examples []struct {
Command string `json:"command"`
} `json:"examples"`
}
if err := json.Unmarshal(raw, &a); err != nil {
t.Fatalf("overlay is not valid affordance JSON: %v", err)
}
if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" {
t.Errorf("overlay missing use_when/examples: %s", raw)
}
// Misses: unknown method in a known service, and an unknown service, both
// resolve to ok=false (no panic, no error) so callers treat them as "no
// guidance".
if _, ok := For("approval", "instances.no_such_method"); ok {
t.Error("unknown method should be ok=false")
}
if _, ok := For("no_such_service", "x.y"); ok {
t.Error("unknown service should be ok=false")
}
// A second lookup of the same service is served from cache (parsed at most
// once) and stays consistent.
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
// dropped (regression: they previously only updated pending, lost without a fence).
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." {
t.Errorf("Tips paragraph dropped: %v", a.Tips)
}
if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}

View File

@@ -1,180 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package affordance
import (
"regexp"
"strings"
"github.com/larksuite/cli/internal/meta"
)
// The affordance source is a narrow, fixed markdown subset (see src/*.md):
//
// # domain optional `> skill: <name>` applied to every method
// ## command e.g. `instances get`
// <lead paragraph> -> use_when (when this command is right)
// ### Avoid when -> avoid_when (links become prefer/alternative edges)
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
// Parsing is lazy and cached (see For), so the constrained grammar is read at
// most once per domain.
var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`)
// standardSection maps a section heading to its typed Affordance field; any
// other heading becomes an extension.
var standardSection = map[string]string{
"Avoid when": "avoid_when",
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
label string
items []string
cases []meta.AffordanceCase
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
out := map[string]meta.Affordance{}
var skill, curKey string
var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates)
var secs []*mdSection
var sec *mdSection
var pending string
var fence []string
inFence := false
assemble := func() {
if curKey == "" {
return
}
if len(para) > 0 {
useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " ")))
para = nil
}
var a meta.Affordance
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
a.AvoidWhen = s.items
case "prerequisites":
a.Prerequisites = s.items
case "tips":
a.Tips = s.items
case "examples":
a.Examples = s.cases
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false }
// flushPending appends a non-bullet paragraph line that was not consumed as
// an example description (i.e. no fence followed) to the current section's
// items, so prose under any section is preserved rather than dropped.
flushPending := func() {
if sec != nil && pending != "" {
sec.items = append(sec.items, linkToBacktick(pending))
pending = ""
}
}
for _, raw := range strings.Split(string(src), "\n") {
line := strings.TrimRight(raw, "\r")
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
flushPending()
assemble()
curKey = resolve(line[3:])
reset()
continue
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
sec = &mdSection{label: strings.TrimSpace(line[4:])}
secs = append(secs, sec)
pending, fence, inFence = "", nil, false
continue
}
if curKey == "" {
continue
}
if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries)
if t == "" {
if len(para) > 0 {
useWhen = append(useWhen, strings.Join(para, " "))
para = nil
}
} else {
para = append(para, t)
}
continue
}
// inside a section: a fenced block is an example command; otherwise the
// shape follows the writing (bullet item vs **description** before a fence).
if strings.HasPrefix(t, "```") {
if !inFence {
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue
}
if inFence {
fence = append(fence, line)
continue
}
if strings.HasPrefix(t, "-") {
flushPending()
sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:])))
} else if t != "" {
flushPending()
pending = strings.Trim(t, "* ")
}
}
flushPending()
assemble()
return out
}

View File

@@ -5,39 +5,30 @@ package meta
import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
// Affordance is the hand-authored usage guidance overlaid on a method: when to
// use it, when not to, prerequisites, few-shot examples, and related methods.
// It is the single typed model of the affordance shape; the envelope renderer
// and the command help both parse through ParsedAffordance so the vocabulary
// is defined once. The JSON tags double as the envelope's wire shape.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Tips []string `json:"tips,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Extensions []AffordanceSection `json:"extensions,omitempty"`
Related []string `json:"related,omitempty"`
Skills []string `json:"skills,omitempty"`
UseWhen []string `json:"use_when,omitempty"`
DoNotUseWhen []string `json:"do_not_use_when,omitempty"`
Prerequisites []string `json:"prerequisites,omitempty"`
Examples []AffordanceCase `json:"examples,omitempty"`
Related []string `json:"related,omitempty"`
}
// AffordanceCase is one few-shot example: a description and a ready-to-run command.
// AffordanceCase is one few-shot example: a one-line description and a
// ready-to-run command.
type AffordanceCase struct {
Description string `json:"description,omitempty"`
Description string `json:"description"`
Command string `json:"command"`
}
// AffordanceSection is a custom guidance section: any heading beyond the
// standard four (Avoid when / Prerequisites / Tips / Examples) flows through
// here with its label preserved, so authors can add sections without code
// changes.
type AffordanceSection struct {
Label string `json:"label"`
Items []string `json:"items,omitempty"`
}
// ParsedAffordance decodes the method's overlay. ok is false when it is absent,
// malformed, or wholly empty — callers treat all three as "no guidance".
// ParsedAffordance decodes the method's raw affordance overlay into the typed
// Affordance. ok is false when the method carries no affordance, the JSON is
// malformed, or every section is empty — so callers can treat "no guidance"
// uniformly.
func (m Method) ParsedAffordance() (Affordance, bool) {
if len(m.Affordance) == 0 {
return Affordance{}, false
@@ -46,7 +37,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) {
if json.Unmarshal(m.Affordance, &a) != nil {
return Affordance{}, false
}
if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 {
if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 {
return Affordance{}, false
}
return a, true

View File

@@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) {
notOK := map[string]string{
"empty payload": ``,
"empty object": `{}`,
"all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`,
"all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`,
"malformed string": `"not an object"`,
"malformed number": `42`,
"nested type mismatch": `{"examples":"should be a list"}`,
@@ -35,9 +35,8 @@ func TestMethod_ParsedAffordance(t *testing.T) {
// Populated affordance parses with all fields.
raw := `{
"use_when": ["需要拿到当前用户的主日历 ID"],
"avoid_when": ["已知具体 calendar_id"],
"do_not_use_when": ["已知具体 calendar_id"],
"prerequisites": ["user 身份登录"],
"tips": ["主日历的 calendar_id 即当前用户的 union_id"],
"examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}],
"related": ["calendars.list"]
}`
@@ -48,22 +47,10 @@ func TestMethod_ParsedAffordance(t *testing.T) {
if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" {
t.Errorf("Tips = %v", a.Tips)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 1 || a.Related[0] != "calendars.list" {
t.Errorf("Related = %v", a.Related)
}
// A method whose only guidance is Tips still parses as populated.
tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance()
if !ok {
t.Fatal("ParsedAffordance with only tips ok=false, want populated")
}
if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" {
t.Errorf("Tips = %v", tipsOnly.Tips)
}
}

View File

@@ -66,9 +66,7 @@ func namedPlaceholderValue(value string) bool {
case "...", "placeholder", "redacted", "<redacted>", "xxxx", "test-secret":
return true
}
return strings.Contains(value, "cli_example") ||
allXPlaceholder(value) ||
conventionalNamedPlaceholderValue(value)
return strings.Contains(value, "cli_example") || allXPlaceholder(value)
}
func allXPlaceholder(value string) bool {
@@ -83,41 +81,6 @@ func allXPlaceholder(value string) bool {
return true
}
func conventionalNamedPlaceholderValue(value string) bool {
if !delimitedPlaceholderIdentifier(value) {
return false
}
normalized := strings.ReplaceAll(value, "-", "_")
if rest, ok := strings.CutPrefix(normalized, "your_"); ok {
return conventionalCredentialPlaceholderName(rest)
}
if rest, ok := strings.CutSuffix(normalized, "_here"); ok {
return conventionalCredentialPlaceholderName(rest)
}
return false
}
func conventionalCredentialPlaceholderName(value string) bool {
switch value {
case "api_key",
"access_key",
"private_key",
"secret",
"password",
"passwd",
"token",
"webhook",
"access_token",
"refresh_token",
"bearer_token",
"session_token",
"client_secret":
return true
default:
return false
}
}
func urlWithAnglePlaceholder(value string) bool {
if !strings.Contains(value, "://") ||
!strings.Contains(value, "<") ||

View File

@@ -4,8 +4,6 @@
package publiccontent
import (
"encoding/base64"
"encoding/json"
"fmt"
"path/filepath"
"sort"
@@ -65,15 +63,12 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
if isSchemaDottedIdentifier(line, match) {
continue
}
out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match)))
}
for _, match := range bearerHeaderRE.FindAllString(line, -1) {
if isPlaceholderBearerHeader(match) {
continue
}
for range bearerHeaderRE.FindAllString(line, -1) {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
}
for _, match := range credentialURLRE.FindAllString(line, -1) {
@@ -396,6 +391,10 @@ func credentialNameFragment(value string) bool {
return false
}
func isSchemaDottedIdentifier(line, match string) bool {
return strings.Contains(line, "schema ") && strings.Contains(match, "_")
}
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[":
@@ -405,40 +404,6 @@ func isNonSecretLiteralValue(value string) bool {
}
}
func isJWTToken(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 3 {
return false
}
header, err := decodeBase64URLSegment(parts[0])
if err != nil || !json.Valid(header) {
return false
}
var fields map[string]interface{}
if err := json.Unmarshal(header, &fields); err != nil {
return false
}
alg, ok := fields["alg"].(string)
return ok && alg != ""
}
func decodeBase64URLSegment(value string) ([]byte, error) {
if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return decoded, nil
}
return base64.URLEncoding.DecodeString(value)
}
func isPlaceholderBearerHeader(match string) bool {
normalized := strings.ToLower(match)
idx := strings.LastIndex(normalized, "bearer ")
if idx < 0 {
return false
}
value := strings.TrimSpace(match[idx+len("bearer "):])
return isPlaceholderValue(value)
}
func isWebhookCredentialKey(key string) bool {
return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook")
}
@@ -776,12 +741,7 @@ func sanitizeSemanticExcerpt(text string) string {
text = strings.ReplaceAll(text, `<redacted>"`, `<redacted>`)
text = strings.ReplaceAll(text, `<redacted>'`, `<redacted>`)
text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer <redacted>")
text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string {
if isJWTToken(match) {
return "<jwt-like-token>"
}
return match
})
text = jwtLikeRE.ReplaceAllString(text, "<jwt-like-token>")
text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL)
return strings.Join(strings.Fields(text), " ")
}

View File

@@ -211,7 +211,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) {
}
func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) {
got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n"))
if !findingRules(got)["public_content_detector_fingerprint"] {
t.Fatalf("expected detector fingerprint finding, got %#v", got)
}
@@ -549,7 +549,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) {
}
func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) {
got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{
got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{
"inPrivateKey = true",
"inPrivateKey = false",
"hasPrivateKey: false",
@@ -725,7 +725,7 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
@@ -734,7 +734,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -743,7 +743,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
}
func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{
got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{
"AppSecret: stored,",
"AccessToken: result.Token.AccessToken,",
`token := runtime.Str("token")`,
@@ -756,7 +756,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) {
}
func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{
got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{
"def normalize_token(value: str) -> str:",
" token = rest[index]",
" next_token = rest[index + 1] if index + 1 < len(rest) else None",
@@ -771,7 +771,7 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) {
}
func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{
got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{
`<img token="..." url="https://..." width="..." height="..."/>`,
`<sheet token="..." sheet-id="...">`,
}, "\n")+"\n"))
@@ -783,7 +783,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) {
}
func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got)
@@ -791,38 +791,8 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) {
}
}
func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) {
got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{
"| Method | Permission |",
"| --- | --- |",
"| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |",
"| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |",
"| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got)
}
}
}
func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) {
got := ScanFile("docs/api.md", []byte(strings.Join([]string{
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"corehr:employment.international_assignment.custom_field.apaas_id__c:read",
"user_mailbox.sent_messages.get_recall_detail queries recall detail.",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_jwt_like_token" {
t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got)
}
}
}
func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"1704067200"}`,
`{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`,
}, "\n")+"\n"))
@@ -835,7 +805,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
@@ -851,7 +821,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
}
func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
`{ "block_token": "boardXXXX" }`,
`{ "resource_token": "doc_token_or_url" }`,
`{ "token": "canonical_token" }`,
@@ -871,7 +841,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
@@ -887,7 +857,7 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
}
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got)
}
@@ -988,19 +958,6 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) {
}
}
func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) {
got := ScanFile("docs/auth.md", []byte(strings.Join([]string{
"Authorization: Bearer YOUR_ACCESS_TOKEN",
`{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`,
"Authorization: Bearer <access-token>",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_bearer_header" {
t.Fatalf("bearer placeholder should not be bearer finding: %#v", got)
}
}
}
func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
token := "abcdefghijklmnopqrstuvwxyz"
text := "private launch plan for internal rollout on Friday\n" +
@@ -1018,22 +975,6 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) {
}
}
func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) {
text := "private launch plan for internal rollout on Friday\n" +
"Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n"
got := semanticCandidate("docs/public.md", "file", text, 1)
if len(got) != 1 {
t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got)
}
if strings.Contains(got[0].Excerpt, "<jwt-like-token>") {
t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0])
}
if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") {
t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0])
}
}
func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) {
text := strings.Join([]string{
"Generated with automated code assistant",
@@ -1071,37 +1012,6 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) {
}
}
func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: YOUR_CLIENT_SECRET",
"api_key: YOUR_API_KEY",
"password: YOUR_PASSWORD",
"access_token: ACCESS_TOKEN_HERE",
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"

View File

@@ -4,11 +4,8 @@
package schema
import (
"regexp"
"sort"
"strings"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
@@ -25,7 +22,7 @@ func Convert(f meta.Field) Property {
if f.Type == "file" {
p.Format = "binary"
}
p.Description = normalizeDesc(f.Description)
p.Description = f.Description
p.Default = f.CoercedDefault()
p.Example = f.CoercedExample()
p.Minimum = f.MinBound()
@@ -55,24 +52,6 @@ func Convert(f meta.Field) Property {
return p
}
var (
sepRunRe = regexp.MustCompile(`[;]{2,}`)
spaceRunRe = regexp.MustCompile(`[ \t]{2,}`)
)
// normalizeDesc de-crufts a meta_data description for the envelope — strips
// markdown emphasis and collapses doubled separators/spaces — but keeps content
// (links, newlines, sentences); the compact flag-help has its own stricter pass.
func normalizeDesc(s string) string {
if s == "" {
return ""
}
s = strings.ReplaceAll(s, "**", "")
s = sepRunRe.ReplaceAllString(s, "; ")
s = spaceRunRe.ReplaceAllString(s, " ")
return strings.TrimRight(s, " ;;。.,、\n")
}
// enumSchema splits coerced enum options into the parallel enum / enumDescriptions
// arrays for the envelope. enumDescriptions is nil unless at least one value
// carries a description (so the bare-enum form stays values-only), keeping the
@@ -107,18 +86,6 @@ func propsOf(fields []meta.Field) *OrderedProps {
return op
}
// paramPropsOf is propsOf for the params section: each property also carries
// its CLI flag (--kebab-name).
func paramPropsOf(fields []meta.Field) *OrderedProps {
op := &OrderedProps{}
for _, f := range fields {
p := Convert(f)
p.Flag = "--" + f.FlagName()
op.Set(f.Name, p)
}
return op
}
// requiredOf returns the alphabetized names of the required fields.
func requiredOf(fields []meta.Field) []string {
var required []string
@@ -141,17 +108,16 @@ func buildInputSchema(m meta.Method) *InputSchema {
Properties: &OrderedProps{},
}
addInputObject(is, "params", "", m.Params(), true, "")
addInputObject(is, "data", "", m.Data(), false, "--data")
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files(), false, "--file")
addInputObject(is, "params", "", m.Params())
addInputObject(is, "data", "", m.Data())
addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file <key>=<path>.", m.Files())
if m.Risk == core.RiskHighRiskWrite {
falseVal := false
is.Properties.Set("yes", Property{
Type: "boolean",
Flag: "--yes",
Default: falseVal,
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.",
Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.",
})
}
@@ -159,24 +125,20 @@ func buildInputSchema(m meta.Method) *InputSchema {
return is
}
// addInputObject adds one section (params/data/file) when it has fields, marking
// the section required at top level when any field is. asFlags tags each property
// with its --flag (params only); carrier names the section's flag (--data/--file).
func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) {
// addInputObject adds one named sub-object section (params/data/file) to the
// input schema when it has fields: its Properties come from the fields, its
// Required lists the mandatory keys, and the section itself is required at top
// level when any field is required. Empty sections are skipped.
func addInputObject(is *InputSchema, name, description string, fields []meta.Field) {
if len(fields) == 0 {
return
}
props := propsOf(fields)
if asFlags {
props = paramPropsOf(fields)
}
req := requiredOf(fields)
is.Properties.Set(name, Property{
Type: "object",
Description: description,
Carrier: carrier,
Required: req,
Properties: props,
Properties: propsOf(fields),
})
if len(req) > 0 {
is.Required = append(is.Required, name)
@@ -217,13 +179,7 @@ func buildMeta(m meta.Method) *Meta {
// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry
// callers use, since apicatalog.MethodRef is the metadata navigation currency.
func EnvelopeOf(ref apicatalog.MethodRef) Envelope {
m := ref.Method
// The affordance overlay lives in the CLI, not the metadata; look it up
// lazily here (it takes precedence over any affordance the metadata carries).
if raw, ok := affordance.For(ref.Service.Name, m.ID); ok {
m.Affordance = raw
}
return assemble(ref.Service.Name, ref.ResourcePath, m)
return assemble(ref.Service.Name, ref.ResourcePath, ref.Method)
}
// Envelopes renders the given method refs into envelopes, sorted by name. The
@@ -249,7 +205,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope
return Envelope{
Name: name,
Description: normalizeDesc(m.Description),
Description: m.Description,
InputSchema: buildInputSchema(m),
OutputSchema: buildOutputSchema(m),
Meta: buildMeta(m),

View File

@@ -9,9 +9,7 @@ import (
"reflect"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/registry"
@@ -506,31 +504,6 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by
// service + method id), so a method whose metadata carries none still gets
// guidance in its envelope when an overlay entry exists.
func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) {
// The overlay source is the top-level affordance/ tree, injected at startup;
// inject a fixture so this unit test does not depend on the shipped content.
// Reset afterwards (this binary installs no source by default) for isolation.
t.Cleanup(func() { affordance.SetSource(nil) })
affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(
"# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}})
env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"})
if env.Meta == nil || env.Meta.Affordance == nil {
t.Fatal("expected affordance from the approval overlay, got none")
}
if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 {
t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance)
}
// A method id with no overlay entry carries no affordance.
bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"})
if bare.Meta != nil && bare.Meta.Affordance != nil {
t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},

View File

@@ -13,10 +13,6 @@ import (
)
// Envelope is the MCP Tool spec contract for a single API method command.
//
// The REST route (httpMethod/path) is deliberately NOT exposed: every
// schema-resolvable method already has a typed command, so the raw path would
// only tempt an agent toward the `api` escape hatch.
type Envelope struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -48,15 +44,9 @@ type OutputSchema struct {
// "params" / "data" sub-objects inside inputSchema): it lists which keys
// inside that object's Properties are mandatory. Leaf fields ignore it.
type Property struct {
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
// Flag is the typed CLI flag a params property maps to (e.g. "--folder-id");
// absent on body/file fields, which travel via the section's Carrier.
Flag string `json:"flag,omitempty"`
// Carrier names the flag a whole inputSchema section travels on ("--data" /
// "--file"); empty on the params section, whose properties carry their Flag.
Carrier string `json:"carrier,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
Type string `json:"type,omitempty"`
Description string `json:"description,omitempty"`
Enum []interface{} `json:"enum,omitempty"`
// EnumDescriptions, when present, is parallel to Enum: the human meaning of
// each allowed value, in the same order. Omitted when no value carries a
// description. This is the widely-recognized JSON-Schema extension (VS Code,

View File

@@ -16,14 +16,6 @@ import (
const (
// EnvNoProxy disables automatic proxy support when set to any non-empty value.
EnvNoProxy = "LARK_CLI_NO_PROXY"
// EnvNoProxyWarn suppresses the proxy-detected warning when set to any
// non-empty value, while leaving proxy behavior unchanged. Unlike
// EnvNoProxy (which both silences the warning AND disables the proxy), this
// keeps proxy egress active. It exists so agents consuming --format json can
// keep using the proxy without the human-oriented warning line landing in
// the output stream and breaking JSON parsing.
EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN"
)
// proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads.
@@ -81,11 +73,6 @@ func redactProxyURL(raw string) string {
// are redacted. Safe to call multiple times; only the first call prints.
func WarnIfProxied(w io.Writer) {
proxyWarningOnce.Do(func() {
// Explicit opt-out: silence the warning without touching proxy behavior.
// Checked before the plugin and env-proxy branches so it suppresses both.
if os.Getenv(EnvNoProxyWarn) != "" {
return
}
// Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see
// Shared), so its warning and disable instructions take precedence.
// Emitting the env-proxy warning here would be misleading: it tells the
@@ -101,7 +88,7 @@ func WarnIfProxied(w io.Writer) {
if key == "" {
return
}
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n",
key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn)
fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n",
key, redactProxyURL(val), EnvNoProxy)
})
}

View File

@@ -93,47 +93,6 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) {
}
}
// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN
// suppresses the warning while the proxy stays configured (unlike
// LARK_CLI_NO_PROXY, which also disables the proxy).
func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
proxyWarningOnce = sync.Once{}
t.Setenv("HTTPS_PROXY", "http://proxy:8080")
t.Setenv(EnvNoProxyWarn, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
}
}
// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that
// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning.
func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
proxyWarningOnce = sync.Once{}
old := proxyPluginStatus
proxyPluginStatus = func() (string, string, bool) { return "http://127.0.0.1:3128", "", true }
t.Cleanup(func() { proxyPluginStatus = old })
t.Setenv(EnvNoProxyWarn, "1")
var buf bytes.Buffer
WarnIfProxied(&buf)
if buf.Len() != 0 {
t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String())
}
}
// TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once.
func TestWarnIfProxied_OnlyOnce(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

View File

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

View File

@@ -5,12 +5,7 @@
const fs = require("fs");
const path = require("path");
const { execFileSync, execFile } = require("child_process");
// @clack/prompts is ESM-only since v1; load it via dynamic import() so this
// CommonJS script works on all supported Node versions (require() of an ESM
// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry
// point below before main() runs.
let p;
const p = require("@clack/prompts");
const PKG = "@larksuite/cli";
const SKILLS_REPO = "https://open.feishu.cn";
@@ -379,12 +374,7 @@ async function main() {
}
}
(async () => {
p = await import("@clack/prompts");
await main();
})().catch((err) => {
const msg = "Unexpected error: " + (err.message || err);
if (p) p.cancel(msg);
else console.error(msg);
main().catch((err) => {
p.cancel("Unexpected error: " + (err.message || err));
process.exit(1);
});

View File

@@ -7,7 +7,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
@@ -806,48 +805,20 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
cmd.Flags().String("detail", fetchDefault("detail"), "")
cmd.Flags().String("lang", fetchDefault("lang"), "")
cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "")
cmd.Flags().String("scope", fetchDefault("scope"), "")
cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "")
cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "")
cmd.Flags().String("keyword", fetchDefault("keyword"), "")
cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "")
cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "")
cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "")
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().String("lang", "", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
// fetchDefault returns the declared default for a flag from the real
// v2FetchFlags definition so tests don't hardcode a stale default.
// It panics if the flag is not found, since a missing flag indicates
// a test setup error rather than a runtime condition.
func fetchDefault(name string) string {
for _, fl := range v2FetchFlags() {
if fl.Name == name {
return fl.Default
}
}
panic(fmt.Sprintf("fetchDefault: flag %q not found in v2FetchFlags", name))
}
// fetchDefaultInt returns the declared default for an int flag from
// v2FetchFlags, parsed as an int. It panics if the flag is not found
// or its default cannot be parsed as an int.
func fetchDefaultInt(name string) int {
s := fetchDefault(name)
if s == "" {
return 0
}
var d int
if _, err := fmt.Sscanf(s, "%d", &d); err != nil {
panic(fmt.Sprintf("fetchDefaultInt: flag %q default %q is not an int", name, s))
}
return d
}
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
@@ -862,17 +833,17 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("api-version", "", "")
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
cmd.Flags().String("doc-format", fetchDefault("doc-format"), "")
cmd.Flags().String("detail", fetchDefault("detail"), "")
cmd.Flags().String("lang", fetchDefault("lang"), "")
cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "")
cmd.Flags().String("scope", fetchDefault("scope"), "")
cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "")
cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "")
cmd.Flags().String("keyword", fetchDefault("keyword"), "")
cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "")
cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "")
cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "")
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().String("lang", "", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
cmd.Flags().String("offset", "", "")
cmd.Flags().String("limit", "", "")
if apiVersion != "" {

View File

@@ -332,11 +332,21 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}, nil
}
// maxBatchOperations caps how many sub-operations a single +batch-update may
// carry. Every translated op (with its own cells/properties payload) is held in
// the out slice at once before the whole batch is marshaled, so an unbounded
// operation count is the same unbounded-materialization hazard as the fan-out
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
}
if len(rawOps) > maxBatchOperations {
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps))
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)

View File

@@ -1,4 +1,59 @@
{
"+formula-verify": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "sheet-name",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "range",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
},
{
"name": "max-locations",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max locations / samples per error type; default 20.",
"default": "20"
},
{
"name": "exit-on-error",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
}
]
},
"+workbook-info": {
"risk": "read",
"flags": [
@@ -73,6 +128,14 @@
"desc": "Initial column count (default 20, max 200)",
"default": "20"
},
{
"name": "type",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet"
},
{
"name": "dry-run",
"kind": "system",
@@ -219,7 +282,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it",
"default": "-1"
},
{
@@ -515,7 +578,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [
"file",
"stdin"
@@ -1069,7 +1132,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Group nesting level to ungroup; default 1 (outermost)",
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)",
"default": "1"
},
{
@@ -1711,6 +1774,13 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2739,7 +2809,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [
"file",
"stdin"
@@ -2759,6 +2829,13 @@
"required": "optional",
"desc": "Font color (hex, e.g. `#000000`)"
},
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{
"name": "font-size",
"kind": "own",
@@ -2885,7 +2962,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -2965,7 +3042,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id",
"input": [
"file",
"stdin"
@@ -3009,7 +3086,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [
"file",
"stdin"
@@ -3127,7 +3204,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"input": [
"file",
"stdin"
@@ -4066,7 +4143,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"
},
{
"name": "dry-run",
@@ -4747,5 +4824,104 @@
"desc": ""
}
]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "end-version",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "history-version-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "History version to revert to (from +history-list)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert-status": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "transaction-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Async revert transaction id (from +history-revert)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -440,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 11 flat style flags and returns the
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
@@ -451,6 +451,9 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -382,6 +382,32 @@ func (p *tablePayload) validate() error {
return common.ValidationErrorf("--sheets[%d] %q: mode %q is invalid (want \"overwrite\" or \"append\")", i, s.Name, s.Mode)
}
}
return p.checkCellBudget()
}
// maxTablePutCells bounds how many cells a single +table-put / +workbook-create
// write may materialize. Unlike the fan-out stamp cap (maxStampMatrixCells),
// these cells come from the caller's own --sheets/--values payload rather than a
// range blow-up, so this is a generous OOM guardrail, not a usability limit:
// buildSheetMatrix builds the whole rows×cols matrix of per-cell maps in memory
// before slicing it into tablePutMaxCellsPerWrite-sized writes, so an unbounded
// payload (2.6M cells ≈ 900MB heap, doubled again by json.Marshal) OOMs the
// process before the first write leaves.
const maxTablePutCells = 1_000_000
// checkCellBudget rejects a payload whose total materialized cell count across
// all sheets exceeds maxTablePutCells. Counted in int64 to stay overflow-safe on
// pathological row/column counts.
func (p *tablePayload) checkCellBudget() error {
var total int64
for i := range p.Sheets {
total += int64(len(p.Sheets[i].Rows)) * int64(len(p.Sheets[i].Columns))
}
if total > maxTablePutCells {
return common.ValidationErrorf(
"--sheets/--values cover %d cells total, over the %d-cell safety cap; split the write across smaller payloads",
total, maxTablePutCells)
}
return nil
}

View File

@@ -123,6 +123,26 @@ func sheetCreateInput(runtime flagView, token string) (map[string]interface{}, e
if strings.TrimSpace(runtime.Str("title")) == "" {
return nil, common.ValidationErrorf("--title is required")
}
// --type bitable 建一张空白多维表格子表operation=create_bitable默认 sheet 为普通
// 电子表格子表。bitable 子表内容编辑走 lark-base 命令row-count/col-count 不适用。
sheetType := strings.TrimSpace(runtime.Str("type"))
if sheetType == "" {
sheetType = "sheet"
}
if sheetType != "sheet" && sheetType != "bitable" {
return nil, common.ValidationErrorf("--type must be 'sheet' or 'bitable'")
}
if sheetType == "bitable" {
input := map[string]interface{}{
"excel_id": token,
"operation": "create_bitable",
"sheet_name": strings.TrimSpace(runtime.Str("title")),
}
if runtime.Changed("index") {
input["target_index"] = runtime.Int("index")
}
return input, nil
}
if n := runtime.Int("row-count"); n < 0 || n > 50000 {
return nil, common.ValidationErrorf("--row-count must be between 0 and 50000")
}
@@ -836,13 +856,19 @@ func buildValuesPayload(runtime flagView, sheetStyles *workbookCreateSheetStyles
cols[i] = tableColumnSpec{Name: fmt.Sprintf("col%d", i+1)} // type-less
}
noHeader := false
return &tablePayload{Sheets: []tableSheetSpec{{
payload := &tablePayload{Sheets: []tableSheetSpec{{
Name: valuesSheetName,
Mode: "overwrite",
Header: &noHeader,
Columns: cols,
Rows: rows,
}}}, nil
}}}
// --values bypasses tablePayload.validate(), so enforce the cell budget here
// too — otherwise a giant --values array materializes unbounded.
if err := payload.checkCellBudget(); err != nil {
return nil, err
}
return payload, nil
}
// parseValuesRows decodes --values (JSON 2D array, with @file/stdin already
@@ -1246,7 +1272,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_size", "font_weight", "font_style", "font_line",
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap":
return true

View File

@@ -111,10 +111,10 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
// CellsSetStyle stamps a single style block across every cell in --range.
// Style is composed from a dozen flat flags (background-color, font-color,
// font-size, font-style, font-weight, font-line, horizontal-alignment,
// vertical-alignment, word-wrap, number-format) plus --border-styles for
// the only field that still needs a nested object. At least one flag must
// be set.
// font-family, font-size, font-style, font-weight, font-line,
// horizontal-alignment, vertical-alignment, word-wrap, number-format) plus
// --border-styles for the only field that still needs a nested object. At
// least one flag must be set.
var CellsSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-set-style",
@@ -165,6 +165,9 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
if err := requireAnyStyleFlag(runtime); err != nil {
return nil, err
}
@@ -450,6 +453,9 @@ func dropdownSetInput(runtime flagView, token, sheetID, sheetName string) (map[s
if err != nil {
return nil, sheetsValidationForFlag("range", "--range %q: %v", rangeStr, err)
}
if err := checkStampMatrixBudget("range", rangeStr, rows, cols); err != nil {
return nil, err
}
validation, err := buildDropdownValidation(runtime)
if err != nil {
return nil, err
@@ -692,9 +698,30 @@ func letterToColumnIndex(letters string) int {
return n - 1
}
// maxStampMatrixCells bounds how many per-cell maps a fan-out / stamp shortcut
// will materialize from a single A1 range. The backing tools take an explicit
// cells matrix, so the CLI must expand a range like "A1:Z100000" into rows×cols
// maps before sending it — an unbounded blow-up (2.6M cells ≈ 900MB heap, then
// doubled again by json.Marshal) that OOMs the process before the request even
// leaves. 200000 matches the documented --max-cells safety cap.
const maxStampMatrixCells = 200000
// checkStampMatrixBudget rejects a range whose materialized cell count would
// exceed maxStampMatrixCells, before fillCellsMatrix allocates it. rows*cols is
// computed in int64 to stay safe against overflow on pathological ranges.
func checkStampMatrixBudget(flagName, rangeStr string, rows, cols int) error {
if total := int64(rows) * int64(cols); total > maxStampMatrixCells {
return sheetsValidationForFlag(flagName,
"range %q covers %d cells, over the %d-cell safety cap; narrow the range or split it across smaller ranges",
rangeStr, total, maxStampMatrixCells)
}
return nil
}
// fillCellsMatrix returns a rows×cols matrix where every cell is the same
// (shallow-copied) prototype map. Use for fan-out shortcuts that stamp a
// single attribute (style / data_validation) across an entire range.
// Callers MUST gate the dimensions through checkStampMatrixBudget first.
func fillCellsMatrix(rows, cols int, prototype map[string]interface{}) [][]interface{} {
cells := make([][]interface{}, rows)
for r := range cells {

View File

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

View File

@@ -105,6 +105,9 @@ func shortcutList() []common.Shortcut {
CellsSearch,
CellsReplace,
// lark_sheet_formula_verify
FormulaVerify,
// lark_sheet_write_cells
CellsSet,
CellsSetStyle,
@@ -148,5 +151,10 @@ func shortcutList() []common.Shortcut {
CellsBatchClear,
DropdownUpdate,
DropdownDelete,
// lark_sheet_history
HistoryList,
HistoryRevert,
HistoryRevertStatus,
}
}

View File

@@ -8,6 +8,10 @@ metadata:
cliHelp: "lark-cli contact --help"
---
# contact (v2)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## 选哪个命令
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:

View File

@@ -1,12 +1,13 @@
# +search-user
支持 user 身份。
仅 user 身份。需要 scope `contact:user:search`
## 适用范围
- ✅ 已知姓名 / 邮箱 / 「聊过的人」想找出 open_id
- ✅ 已知一组 open_id 想批量校验或回填字段(`--user-ids`,最多 100,支持 `me`)
- ✅ 按聊天关系 / 在职状态 / 租户边界 / 企业邮箱等维度筛选员工
- ❌ 已知 open_id 想拿完整 profile → 用 `+get-user --as bot`
- ❌ 已知 open_id 想发消息 → 直接走 `lark-im`,不经过本命令
## 关键 flag

View File

@@ -1,7 +1,7 @@
---
name: lark-event
version: 1.0.0
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume <EventKey>` (covers IM messages/reactions/chat changes, Task updates, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses."
metadata:
requires:
bins: ["lark-cli"]
@@ -149,6 +149,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val
|------------|------------------------------------------------------------------------------|---|
| IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) |
| Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) |
| Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) |
| Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=<token>`) + payload field reference (whiteboard_id / operator_ids triple-id) |

View File

@@ -2,60 +2,48 @@
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (4)
## Key catalog (2)
| EventKey | Purpose |
|---|---|
| `vc.meeting.participant_meeting_started_v1` | A meeting the current user participates in has started |
| `vc.meeting.participant_meeting_joined_v1` | The current user has joined a meeting |
| `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended |
| `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) |
All four keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. All require `--as user`.
Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both require `--as user`.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `vc.meeting.participant_meeting_started_v1` | `vc:meeting.meetingevent:read` | user |
| `vc.meeting.participant_meeting_joined_v1` | `vc:meeting.meetingevent:read` | user |
| `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user |
| `vc.note.generated_v1` | `vc:note:read` | user |
---
## Meeting participant events
Covered keys:
- `vc.meeting.participant_meeting_started_v1`
- `vc.meeting.participant_meeting_joined_v1`
- `vc.meeting.participant_meeting_ended_v1`
## `vc.meeting.participant_meeting_ended_v1`
### Output fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Event type; one of the covered meeting participant EventKeys |
| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` |
| `event_id` | string | Globally unique event ID; safe for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) |
| `meeting_id` | string | Meeting ID |
| `topic` | string | Meeting topic |
| `meeting_no` | string | Meeting number |
| `start_time` | string | Meeting start time in RFC3339, converted to the local timezone |
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone |
| `calendar_event_id` | string | Calendar event ID associated with the meeting |
| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone; only present for `vc.meeting.participant_meeting_ended_v1` |
### Gotchas
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty. `end_time` is emitted only for `vc.meeting.participant_meeting_ended_v1`.
- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty.
- No detail API call is made; all fields come from the event payload itself.
### Example
```bash
lark-cli event consume vc.meeting.participant_meeting_started_v1 --as user
lark-cli event consume vc.meeting.participant_meeting_joined_v1 --as user
lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user
# Project meeting topic and end time only

View File

@@ -1,7 +1,7 @@
---
name: lark-shared
version: 1.0.0
description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, or handling _notice JSON."
description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output."
---
# lark-cli 共享规则
@@ -23,27 +23,6 @@ lark-cli config init --new
## 认证
### 认证任务速查
认证、scope、业务域、登录态、退出登录态、撤销授权问题都走本技能。
| 用户意图 | 首选命令 / 回答 |
|---|---|
| 获取全部权限 | `lark-cli auth login --domain all --no-wait --json` |
| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json``--domain` 可重复,也可用逗号分隔 |
| 指定单个 scope 授权 | `lark-cli auth login --scope "<scope>" --no-wait --json` |
| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity``verified``identities.user.status``identities.user.userName``identities.user.openId`(用户 open id`identities.user.tokenStatus``identities.user.scope` |
| 退出当前机器的用户登录态 | `lark-cli auth logout --json``loggedOut:true` 表示注销成功 |
| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope优先复用错误里的 `console_url` |
| 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 |
| 只取消一个 scope | CLI 不支持单独撤销一个已授予 scope可重新走最小 scope 授权,或让用户在授权管理页处理 |
机器读取 JSON 时,为减少 `_notice` 干扰,可在命令前加:
```bash
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli auth status --json --verify
```
### 身份类型
两种身份类型,通过 `--as` 切换:
@@ -129,22 +108,19 @@ lark-cli auth login --device-code <device_code>
lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_notice.update` 字段(含 `message``command` 等)。
除非用户正在询问更新、版本或 notice否则不要把 `_notice` 原样复制为当前任务的主要答案,也不要为了 notice 中断当前任务去反复查 help。
**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**
需要稳定 JSON 给脚本或机器读取时,可以在命令前设置:
```bash
LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 <lark-cli command>
```
当你在输出中看到 `_notice.update` 时,先完成用户当前请求;如仍相关,再简短告知可运行:
```bash
lark-cli update
```
1. 告知用户当前版本和最新版本号
2. 提议执行更新(同时更新 CLI 和 Skills
```bash
lark-cli update
```
3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。
## 安全规则
- **禁止输出密钥**appSecret、accessToken到终端明文。

View File

@@ -1,6 +1,6 @@
---
name: lark-sheets
version: 3.0.0
version: 3.0.1
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
@@ -32,52 +32,116 @@ metadata:
| 透视表 pivot | `--pivot-table-id` | 迷你图(按组) | `--group-id` |
| 浮动图片 | `--float-image-id` | | |
## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效)
下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。
1. **最小改动**:除任务要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。
3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值;**凡可由表内其它单元格推导的派生值默认就用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`
5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。
7. **分组汇总用透视表**"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。
8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。
9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。
> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流为:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag先对照本表避免一次必然失败的试错。完整 shortcut 见各工具参考。
把高频意图映射到**真实存在**的 shortcut / flag。agent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag先对照本表避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**:命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则。
| 你要做的事 | ✅ 正确写法 | ❌ 不存在(会被 cobra 拒) |
| --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | `+get-range``+range-get``+cells-read` |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `+get-cell``+cell-get``--with-styles``--with-merges``--include-merged-cells` |
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视 | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力 |
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`date / number 不丢,详见 workbook | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range` | — |
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | — |
| 插图:**自由摆放、不绑数据**的装饰 / 标识logo / 水印 / 封面大图 / banner | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | — |
| 查找单元格 | `+cells-search`(关键字用 `--find` | `+cells-find``+find``--query` |
| 查找并替换 | `+cells-replace` | — |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | `+sheet-list``+workbook-get``+workbook-list` |
| 导 xlsx / 单表 csv | `+workbook-export` | — |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌 |
| 清除内容 / 格式 | `+cells-clear`范围维度用 `--scope`,取值 content / formats / all | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) |
| --- | --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | `lark-sheets-read-data` | `+get-range``+range-get``+cells-read` |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell``+cell-get``--with-styles``--with-merges``--include-merged-cells` |
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | `lark-sheets-write-cells` | — |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里 | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并不必事后再刷payload 里不存在的 sheet 名会自动建子表,详见 write-cells | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力);或以"只是 leaderboard / 报表展示、又不用算"为由把百分比写成 `"54%"`(展示用途不改变"百分比是数值"的事实 |
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`date / number 不丢`--styles` 同样可在建表同一步带全套样式,详见 workbook | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可) | `lark-sheets-write-cells` | — |
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — |
| 插图:**自由摆放、不绑数据**的装饰 / 标识logo / 水印 / 封面大图 / banner | `+float-image-create`(浮动图片,自由定位 + 尺寸 + 层级) | `lark-sheets-float-image` | — |
| 查找 / 替换文本 | `+cells-search`找,关键字用 `--find``+cells-replace`(替换) | `lark-sheets-search-replace` | `+cells-find``+find``--query` |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `lark-sheets-sheet-structure` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | `lark-sheets-workbook` | `+sheet-list``+workbook-get``+workbook-list` |
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`(导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import` |
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all | `lark-sheets-range-operations` | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `lark-sheets-batch-update` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 |
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create` | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight``+conditional-format`、逐格 `+cells-set-style` 硬凑 |
| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create` |
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**:本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`;只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错)。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过。
> ⚠️ **两种图片别选错**:图若**绑定某条记录、要随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`只是自由摆放的装饰logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 `+table-put`写入已有表;外层 `{"sheets":[...]}` 包裹、列 pandas dtype 用 `dtypes`、展示格式用 `formats`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`只有纯文本用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 字符串再走 `+csv-put`。
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:要写的列里有金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入,**与"当下要不要排序 / 求和"无关**"只是 leaderboard / 报表展示、不用算"不是写成 `"54%"` 文本的理由——展示用途不改变百分比是数值的事实):常规二维表用 `+table-put`(外层 `{"sheets":[...]}` 包裹、列 pandas dtype 用 `dtypes`、展示格式用 `formats`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才算纯文本用 `+csv-put` 平铺;别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`(落成文本、不能参与计算)。**当版式 `+table-put` 装不下时**(多级 / 合并表头、每方向占两列的宽表 leaderboard或同批要混写公式 / 批注)——用 `+cells-set` 而非退回 `+csv-put` 字符串`value` 直接传数字(百分比传小数 `0.4`、金额传 `1234.5`+ `cell_styles.number_format``"0%"` / `"#,##0.00"`),照样显示 `40%` 且数值无损。**版式复杂只决定用哪个命令,绝不是拼 `"40%"` 字符串的理由。**
> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**`+table-put` / `+workbook-create` 的 `--styles` 能在写数据的**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里若 sheet 名不在工作簿中会自动新建子表——**纯文本表要新建子表 + 美化时同样走这里**`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式」少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 规范用 `--start-cell`(单个左上角锚点格),也接受 `--range` 别名(区间自动取左上角),二者择一即可。
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`**没有** `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag。
## 执行要点(读取 / 原生工具 / 陷阱)
准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
### 读取:按需求选路径(细则见 `lark-sheets-read-data`
| 用户需求 | 读取路径 |
|---|---|
| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准) |
| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
| 需要公式 / 样式 / 批注 | `+cells-get` |
| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5 |
> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3
### 计算:原生工具优先,代码兜底(强化准则 7
| 用户需求 | 用原生 | 禁止的替代 |
|---|---|---|
| 按 X 统计 Y、分组汇总 | `+pivot-{create\|update\|delete}` | pandas groupby → 写值 |
| 求和 / 计数 / 平均 / 占比 | 公式 | Python 算 → 写静态值 |
| 图表 / 可视化 | `+chart-*` | matplotlib |
| 条件高亮 / 色阶 | `+cond-format-*` | 逐格设样式 |
| 筛选 | `+filter-*` | pandas filter → 覆盖写入 |
| 文本提取 / 转换 / 查找 | 公式REGEXEXTRACT / TEXT / VLOOKUP 等) | Python → 写静态值 |
只有多步清洗、统计建模、公式试错 3 次仍失败时才用代码。
### 用脚本配合 CLI 时
- **只读 stdout**CLI 数据走 stdout、诊断走 stderr解析 JSON 别 `2>&1`(警告混入会解析失败),用管道或单独重定向 stdout。
- **喂 CLI 的 CSV / JSON 用 UTF-8 无 BOM**;临时文件放系统临时目录、勿落项目目录。
- **命令失败先读 stderr 再调整**,别原样重发。
- **回写纯单元格值**:剥离 `值(V-Align: bottom)` 这类"值(样式)"串与残留引号再写;排序优先 `+range-sort` 原生工具,别"读出本地排完再整列写回"。
### 易漏陷阱
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update``+rows-resize` 补齐。
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行查 `#VALUE!` / `#REF!` / `#DIV/0!`;同一方案试错上限 3 次。
- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。
- **隐藏行列**`+csv-get` 默认含隐藏行列;设 `--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前先 `+workbook-info` 掌握全局。
- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`
## References
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的工作流、铁律、样式、公式规则,不含具体 shortcut它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,其中的铁律对所有工具参考一律生效。
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式、公式规则,不含具体 shortcut它们规定了"怎么做对";再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut
| Reference | 描述 |
| --- | --- |
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框、数字格式等取值标准,以及从零新建表格的版式美化、新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开时使用。 |
### 按对象的工具参考(含 shortcut
| Reference | 描述 |
| --- | --- |
| [飞书表格公式自检](references/lark-sheets-formula-verify.md) | 公式写入后的自检入口。对指定子表(或整本工作簿)扫描公式与单元格值,聚合所有 Excel 错误(#REF! / #DIV/0! / #VALUE! / #NAME? / #NULL! / #NUM! / #N/A同时合并最近一次写入留下的编译失败formula_errors输出统一 JSON 让 AI 一次拿到完整健康度报告。任何批量公式 / 含公式列写入完成后调用 +formula-verify 确认 zero-errorstatus='errors_found' 时禁止把链路标为完成。 |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
@@ -92,6 +156,7 @@ metadata:
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本并回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、或查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。 |
## 公共 flag 速查

View File

@@ -51,9 +51,10 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A1:B2","'Sheet2'!D1:D10"]`);前缀必须 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id支持跨 sheet所有 range 应用同一组 style |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A1:B2","Sheet2!D1:D10"]`,前缀裸写不加引号);前缀必须 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id支持跨 sheet所有 range 应用同一组 style |
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff` |
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000` |
| `--font-family` | string | optional | 字体名称(如 `Arial``微软雅黑` |
| `--font-size` | float64 | optional | 字体大小px10、12、14 |
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic` |
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold` |
@@ -70,7 +71,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(如 `["'Sheet1'!A2:A100","'Sheet1'!C2:C100"]`),每项必须带 sheet 前缀;前缀必须 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(如 `["Sheet1!A2:A100","Sheet1!C2:C100"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
| `--options` | string + File + Stdin复合 JSON | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
| `--colors` | string + File + Stdin简单 JSON | optional | 下拉胶囊背景色RGB hex 数组(如 `["#1FB6C1","#F006C2"]`)。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置 10 色色板循环补色。**单独传即生效**`--highlight=false` 时被忽略。 |
| `--multiple` | bool | optional | 启用多选 |
@@ -83,7 +84,7 @@ _公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(最多 100 个,如 `["'Sheet1'!E2:E6"]`),每项必须带 sheet 前缀;前缀必须 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!E2:E6"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
### `+cells-batch-clear`
@@ -91,7 +92,7 @@ _公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["'Sheet1'!A2:Z1000","'Sheet2'!A2:Z1000"]`);前缀必须 sheet 显示名(如 `Sheet1`),不接受 sheet reference_id支持跨 sheet对所有 range 执行同一 scope 的清除 |
| `--ranges` | string + File + Stdin简单 JSON | required | 目标范围 JSON 数组,每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`,前缀裸写不加引号);前缀必须 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id支持跨 sheet对所有 range 执行同一 scope 的清除 |
| `--scope` | string | optional | 清除范围 enum`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all` |
## Schemas
@@ -137,7 +138,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
# ops.json array<{shortcut, input}>shortcut 用 CLI 名):
# [
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","dimension":"row","start":10,"end":12}},
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","position":10,"count":3}},
# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}}
# ]
```
@@ -145,7 +146,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
> ⚠️ **子操作定位规则**
> - spreadsheet 定位(`--url` / `--spreadsheet-token`**只在顶层给一次**`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。
> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON`"range":"A11:B12"`、`"dimension":"row"`),不要把整组 `--operations` 再套一层嵌套 JSON。
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON`"range":"A11:B12"`、`"position":11`),不要把整组 `--operations` 再套一层嵌套 JSON。
> **常见组合:插列 + 写表头 + 整列回填**——一次原子提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
>
@@ -153,7 +154,7 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行
> [
> {"shortcut": "+dim-insert",
> "input": {"sheet_id": "...", "dimension": "column", "start": 3, "end": 4}},
> "input": {"sheet_id": "...", "position": "C", "count": 1}},
> {"shortcut": "+cells-set",
> "input": {"sheet_id": "...", "range": "C1:C100",
> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}}

View File

@@ -31,7 +31,9 @@
**常见配置错误(必须注意)**
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
- **数据标签缺失**用户需要看到具体数值时,需配置 `properties.snapshot.plotArea.plot.labels`(数据标签)相关字段
- **数据标签开关**`plotArea.plot.labels` 对象的**存在性即开关**——
- 用户需要看到具体数值/类别时:传入 `labels` 并配置 `value` / `category` / `series` / `percentage` 等显示位。
- 用户明确说"不要数据标签 / 关掉标签"时:**整个 `labels` 字段省略**。不要用 `labels: { value: false, category: false, series: false }` 这种"全部置 false"的写法关闭——只要传了 `labels`,系统就会显示数据标签(且默认兜底显示 value
- **数据源范围与系列名来源要对齐**
- **默认情况inline 模式)**`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
@@ -122,7 +124,7 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`)。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
| `--properties` | string + File + Stdin复合 JSON | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`;必须至少含 `snapshot.data.dim1.serie.index``dim2.series[].index` 之一,否则 server 拒。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
### `+chart-update`

View File

@@ -43,6 +43,8 @@
**正确做法(两步走)**
Step 1 的 `+cells-set``--copy-to-range` 等 flag 以 `lark-sheets-write-cells` 为准。
```
Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列)
range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100"

View File

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

View File

@@ -50,7 +50,7 @@ _公共四件套 · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin复合 JSON | required | 筛选视图规则 JSON`rules?`(列级筛选规则数组)和 `filtered_columns?``range``view_name` 是独立 flag |
| `--range` | string | required | 筛选视图作用的单元格范围A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段create 必填,必须覆盖表头行 |
| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
| `--view-name` | string | optional | 筛选视图名称;不传时系统自动分配;优先级高于 `--properties` 中同名字段 |
### `+filter-view-update`

View File

@@ -29,9 +29,9 @@
- **`--image <本地路径>`(首选,最省事)**直接给本地图片文件路径PNG/JPEG/GIF/BMP/HEIC 等。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag必须是当前工作目录内的相对路径绝对路径会被 Validate 拒,`--dry-run` 也会拦)。
- `--image-token`:复用**已存在**的图片 file_token。常见来源`+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。
- `--image-uri`:图片 reference_idimage URI由系统自动转 file_token。
- `--image-uri`:图片 URI上传链路返回的句柄**非**表内对象 reference_id由系统自动转 file_token。
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`。
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`;用完清除该临时单元格,避免残留多余图片
## Shortcuts
@@ -122,7 +122,7 @@ lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image-name "logo.png" --image-token "$TOKEN" \
--position-row 0 --position-col A --size-width 200 --size-height 150
# 用 reference_id图片上传链路返回的 image reference_id与 --image-token 二选一)
# 用 image URI上传链路返回的句柄非表内对象 reference_id与 --image-token 二选一)
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image-name "logo.png" --image-uri "$IMAGE_URI" \
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1

View File

@@ -1,14 +1,14 @@
# 飞书表格公式生成规则
> **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、ARRAYFORMULA / 数组语义、高风险引用函数、日期差、不支持函数清单。
> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells` 与 `lark-sheets-core-operations`。本文不含 shortcut铁律见 `lark-sheets-core-operations`
> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `lark-sheets-write-cells`。本文不含 shortcut通用编辑准则见主 SKILL.md「飞书表格编辑准则」
**核心原则:飞书不像 Excel 365 那样默认 spill溢出展开。飞书普通公式遇到区域时默认"投影"(只取当前行/列对应的单个值),必须显式使用 `ARRAYFORMULA` 或原生数组函数才能逐项展开。**
## 公式书写约定(写任何公式都先满足)
- **绝对引用 `$`**:向下 / 向右填充前判断哪些引用要锁定——用户指定的固定 cell`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)、锁行不锁列(`B$1`)。填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。
- **公式字符串用飞书范围语法**:写 `H:H``A2:B5`**禁止** `H2:H` / `2:2`这与 CLI 工具参数(如 `--range`)的 A1 表示法(`A1:D3``1:1`)写法不同,两者混淆会导致调用失败或公式报错。
- **公式字符串用飞书范围语法**:写 `H:H``A2:B5`**禁止** `H2:H` / `2:2`要在公式里引用整行,用显式范围(如 `$A2:$Z2`)替代禁用的 `2:2`。这与 CLI 工具参数(如 `--range` / `--copy-to-range`)的 A1 表示法写法不同:参数侧合法的 `D3:D``1:1``3:6` 在公式串里反而非法。**公式串 ≠ CLI 参数**,两套规则别互相照搬,混用会导致调用失败或公式报错。
## 翻译后必做:代码复现校验
@@ -224,7 +224,7 @@ Excel`{=A1:A10*B1:B10}`Ctrl+Shift+Enter 输入)
## 飞书不支持的函数
> 本段是"飞书不支持函数"的**唯一权威清单**`lark-sheets-core-operations` 不再单列,统一指向这里)。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案:
> 本段是"飞书不支持函数"的**唯一权威清单**。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案:
- `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据
- `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数
@@ -234,6 +234,7 @@ Excel`{=A1:A10*B1:B10}`Ctrl+Shift+Enter 输入)
- `INFO``RTD` — 系统信息 / 实时数据函数,飞书不支持
- `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代
- `AMORDEGRC``PHONETIC``DETECTLANGUAGE` — 飞书不支持
- `LET`、命名自定义函数(名称管理器里定义的 LAMBDA、独立调用的 `LAMBDA`(如 `=LAMBDA(x,x+1)(5)`)— 会报 `#NAME?`;改用嵌套 IF / 辅助列。**例外**`LAMBDA` 作为 `MAP` / `REDUCE` / `BYROW` / `BYCOL` / `SCAN` / `MAKEARRAY` 的内联参数时**支持**(见上方「飞书原生数组函数清单」)
## 代表性改写示例

View File

@@ -0,0 +1,76 @@
# 飞书表格公式自检(+formula-verify
> **本文定位**:飞书表格"公式写入后是否真的零错误"的自检入口。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `lark-sheets-formula-translation` 为唯一权威,本文不重复;本文聚焦"写完了之后怎么用一次调用确认 zero-error"。
>
> **边界**:本文不讲公式怎么写(去 `lark-sheets-formula-translation`),也不讲公式怎么写入表格(去 `lark-sheets-write-cells` / `lark-sheets-batch-update`)。本文只讲一件事:**写完之后必须用 `+formula-verify` 自检到 zero-error 才能交付**。
## 为什么需要自检
飞书在线表格已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口:
- 公式编译失败 → 单元格落成文本(写入类 shortcut 返回的 `formula_errors[]` 是**编译失败**信号)。
- 公式编译成功但**运行时错误**`#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`——这一类只看 `formula_errors[]` 看不到,必须扫单元格值。
`+formula-verify` 把两路信号合并成一份统一 JSON一次调用聚合全表错误清单 + 编译失败清单 + 每类错误的定位与样本AI 一眼就能定位修复,链路也能据 `status` 强制收敛到 `success`
## 调用契约
最小调用形态:
| 入参 | 含义 |
|---|---|
| `--url` / `--spreadsheet-token` | 表格定位XOR 二选一,必填) |
| `--sheet-id` / `--sheet-name` | 限定子表mutually exclusive省略则扫全部可见子表 |
| `--range` | 限定 A1 范围;省略则用各 sheet 的 `current_region` |
| `--max-locations` | 每类错误样本上限,默认 20 |
| `--exit-on-error` | `status='errors_found'` 时返回非 0 退出码CI 网关用) |
返回核心字段:
- `status``success` / `errors_found` / `partial`——**唯一可机读的健康度判据**。
- `total_errors` / `total_formulas` / `scanned_cells`——本次扫描规模指标。
- `has_more`——为 true 表示扫描被内部上限截断(详见后文「截断与续读」),未覆盖完整范围。
- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`
- `compile_errors[]`——合并最近一次写入留下的编译失败清单,与运行时错误并存时同时出现。
- `warning_message`——仅在 `has_more=true` 时出现,告知调用方需要缩小 `--range` / 拆 `--sheet-id` 续读。
## 写入收尾收敛规则
任何批量公式 / 含公式列写入完成后调用 `+formula-verify` 直到 `status='success'` 才能交付。触发场景:
- `+cells-set` / `+cells-csv-set`
- `+sandbox-import`
- `+batch-update` 中含写入子操作
- `+table-put`(任意列含公式时)
- `+workbook-import`(导入的 xlsx 含公式时)
收敛规则:
1. `status='success'` → 通过;可以把链路标完成。
2. `status='partial'` → 扫描被内部上限截断。先缩小 `--range` 或拆 `--sheet-id` 续扫,**不允许**把 `partial` 当作 `success`
3. `status='errors_found'``compile_errors[]` 非空 → **先解决编译失败**:根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式),用 `+cells-set` 重写后再调一次 `+formula-verify`
4. `status='errors_found'` 且只剩运行时错误 → 按 `error_summary``samples[].formula` + `depends_on` 排查根因(零除?空值参与运算?引用越界?日期差写法?数组语义?),修复后重新自检。
5. 同一处错误连续修复 3 次仍未通过 → 改用 `IFERROR` 包裹兜底,或退回纯值写入;不要在 `errors_found` 状态下扩展 `+cells-set --copy-to-range`、追加批量写入。
注意:
-`status='errors_found'` 的状态下调用 `+cells-set --copy-to-range` 继续扩展会把错误复制放大。
- "编译失败但运行时无报错"不是 zero-error编译失败的单元格此刻是文本不是公式源数据一变就再也算不出值
- 跳过自检直接交付、靠肉眼读首末 5 行确认是不可靠的——表中段、隐藏行、合并区里的错误这样根本看不到。
## 截断与续读
后端有一个内部硬上限对总扫描单元格数做截断(不暴露给调用方),超过后立即返回 `has_more=true` + `warning_message``error_summary` / `compile_errors` 仅覆盖已扫描部分。处理路径:
- 把工作簿按 `--sheet-id` / `--sheet-name` 拆成多次调用。
- 同 sheet 内按 `--range` 切片(如先 `A1:Z200``AA1:AZ200`),逐块自检。
- 每块都跑到 `has_more=false``status='success'` 才算通过。
## 常见陷阱
| 坑 | 应对 |
|---|---|
| 错误字符串本地化 | 后端按内部 `error_kind` / `compute_status` 字段识别错误类别,不走字符串匹配;调用方拿到的 7 类英文错误代码由后端统一规范输出,与 locale 无关。 |
| `formatted_value` 可能隐藏错误 | 某些条件格式 / 自定义数字格式会把 `#DIV/0!` 显示成空白。后端直接读 cell `error_kind`,不依赖 `formatted_value`,绕开此类被遮蔽。 |
| 把 `partial``success` | `partial` 仅表示**已扫描部分**无错误,剩余区域未知。必须续扫直到 `has_more=false``status='success'` 才能算通过。 |
| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]``error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检。 |

View File

@@ -0,0 +1,93 @@
# Lark Sheet History
## 概念回顾
每张飞书电子表格保留一串历史版本(`minor_histories`)。每个版本由 `history_version_id` 标识,并附带创建时间(`create_time`)、动作(`action`)与块修订信息(`all_block_revision`)。历史是**工作簿级**的(针对整张电子表格,不针对单个子表)。
回滚revert把电子表格的当前内容覆盖回某个历史版本——这是一个**写入 / 不可逆**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果。
## 使用场景
读取历史版本、发起回滚、查询回滚状态。本 reference 覆盖 3 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version` |
| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 |
| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 |
典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id``+history-revert-status --transaction-id <transaction_id>` 轮询直到成功或失败。
**注意事项(必须了解)**
- **回滚是写入 / 不可逆操作**:会用历史版本内容覆盖当前表格,发起前请确认目标 `history_version_id` 正确。
- **回滚是异步的**`+history-revert` 返回的是 `transaction_id`(受理标识),不代表回滚已完成;必须用 `+history-revert-status --transaction-id <transaction_id>` 确认最终结果。
- **`history_version_id``transaction_id` 不是同一个**`history_version_id` 用于 `+history-revert`(取自 `+history-list``transaction_id` 用于 `+history-revert-status`(取自 `+history-revert` 的输出)。
- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`XOR不需要子表选择器。
- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version``has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+history-list` | read | 历史版本 |
| `+history-revert` | write | 历史版本 |
| `+history-revert-status` | read | 历史版本 |
## Flags
### `+history-list`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--end-version` | int | optional | 分页查询的最大版本(倒序);首次查询省略,下一页传上一页返回的 next_end_version。 |
### `+history-revert`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--history-version-id` | string | required | 要回滚到的历史版本(取自 +history-list |
### `+history-revert-status`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--transaction-id` | string | required | 异步回滚的受理标识(取自 +history-revert |
## Examples
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`XOR二选一`+history-revert``--history-version-id`(取自 `+history-list``+history-revert-status``--transaction-id`(取自 `+history-revert` 的异步受理标识)。
### `+history-list`
```bash
# 列出某张电子表格的最新一页历史版本
lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx"
# 用原始 spreadsheet token 定位
lark-cli sheets +history-list --spreadsheet-token "SHTxxxxxx"
# 翻到下一页:把上次响应里的 next_end_version 作为 --end-version 传入
lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --end-version 12345
```
### `+history-revert`
```bash
# 回滚到指定历史版本(异步受理)
lark-cli sheets +history-revert --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --history-version-id "<id-from-history-list>"
```
### `+history-revert-status`
```bash
# 查询某次回滚的当前状态(进行中 / 成功 / 失败)
lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --transaction-id "<transaction-id-from-history-revert>"
```

View File

@@ -32,9 +32,10 @@
**常见配置错误(必须注意)**
- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误
- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反
- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`"统计总额"→ `"sum"`"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`默认不要 `count` `sum`
- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`"统计总额"→ `"sum"`"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`按用户意图选聚合方式,不要 `count` `sum`
- **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range
- **创建后必须验证**:调用 `+pivot-list` 确认透视表结构正确
- **落点不能覆盖任何已有数据(不只是 `--source` 范围)**:透视表创建后会向右下**展开**,展开区域哪怕只盖到一个已有单元格(即便已避开源数据),也会报「目标位置不能与数据源重叠」并产生 `#REF!`。创建前无法精确预知展开尺寸,故**强烈优先默认策略**(不传 `--target-sheet-id/-name``--target-position`/`--range`,后端自动新建空白子表),零覆盖风险;非要落到已有子表,必须挑一片足够大的纯空白区
- **创建后必须校验(用 `info` 读取展开后的真实占用区域)**:创建后调用 `+pivot-list``info.error_state``info.content_range`/`page_range`——`error_state``None`(如 `Cover` 盖到其它内容 / `Shrink` 展不开)说明落点冲突,应删除后重建到空白区;`content_range`/`page_range` 是展开后**实际占用区域**,可用 `+csv-get` 抽查其边缘外有没有盖掉原有数据,确认结构正确
## Shortcuts
@@ -120,6 +121,10 @@ _创建/更新的透视表属性_
lark-cli sheets +pivot-list --url "..." --sheet-id "$SID"
```
> **返回值含 `info`(展开后的占用区域与状态)**:每个透视表对象除 `position` / `snapshot` 外,还返回 `info`,标明它在 sheet 上的平铺区域与状态——`info.page_range`(筛选/分页区 A1、`info.content_range`(主体数据区 A1、`info.span_range`(空表合并区 A1、`info.error_state`(错误状态,如 `None`/`Cover`/`Shrink`/`Loading`)、`info.is_empty` / `info.is_hidden`、`info.row`/`info.col`(锚点)等。
> **用途 1判断改值还是改配置**:当用户描述某个单元格要改动时,先 `+pivot-list` 拿到 `info`,判断该单元格是否落在 `page_range` / `content_range` 内——**落在区域内 = 属于透视表,应走 `+pivot-update` 改配置**(透视表单元格不能直接 `+cells-set` 改值);**落在区域外 = 普通单元格,正常 `+cells-set` 改值**。
> **用途 2创建后校验覆盖**:建完透视表用 `info.error_state` 判断有没有冲突(非 `None` 即落点/展开区与已有数据重叠或展不开),用 `info.content_range`/`page_range` 拿到展开后真实占用区域再核对是否盖到原有数据。
### `+pivot-create`
> 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag不要再放 `--properties``rows` / `columns` / `values` 等数组字段走 `--properties`。

View File

@@ -22,6 +22,7 @@
注意:
- **`--range` 两种语法别混**`+cells-clear` / `+cells-{merge|unmerge}` / `+range-*` 用单元格 A1 矩形(如 `A2:A10``+rows-resize` / `+cols-resize` 用纯行 / 列区间(行 `2:10`、列 `A:C`),不要给 resize 传 `A2:A10`
- 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`"这列 / 整列"时使用 `J:J`。不要截断为局部矩形
- 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作
- 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测
@@ -35,7 +36,7 @@
2. **判定阈值**:当前列宽(用 `+sheet-info --include row_heights,col_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。
3. **修复二选一**
- **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值)
- **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`),并用 `+rows-resize / +cols-resize` 调高对应行的行高
- **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip``cell_styles` 字段见 `lark-sheets-write-cells`),并用 `+rows-resize / +cols-resize` 调高对应行的行高
4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。

View File

@@ -2,7 +2,7 @@
## 列格式多样性预探(写公式 / 排序 / 筛选前必做)
> 对应 `lark-sheets-core-operations` 的 **R3 计算复现**——本节是 R3 在 read_data 工具层的具体落地。
> 本节给出"写公式 / 排序 / 筛选前先探清列格式多样性"的正确流程,是主 SKILL.md「飞书表格编辑准则」准则 3读全再写在 read_data 工具层的落地。
对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段:
@@ -22,7 +22,7 @@
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|---------|----------------|---------|------|
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more` |
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put` | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验 |
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put` | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传实际读取范围 `range` 供完整性校验。注意这与下文 `current_region` "遇表中部空行截断"不矛盾:`+table-get` 读的是子表物理 used range飞书记录的已用矩形含中间空行`current_region` 是从锚点连通扩展、遇整行空行就断 |
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息token 开销较大 |
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
@@ -42,7 +42,7 @@
注意:
- `+csv-get``+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;使用 `+cells-get` 时,在读取 `cells` 之前必须先 `warning_message`,并用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
- `+csv-get``+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前必须先 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
**常见配置错误(必须注意)**

View File

@@ -39,7 +39,7 @@
**常见配置错误(必须注意)**
- **插入列直接用字母**`+dim-insert``--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引
- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容`+csv-get` / `+cells-get``lark-sheets-read-data`
- **"在 D 列左侧新增一列"的正确写法**`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`
- **`+dim-move` 同维度约束**`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用
- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续
@@ -129,7 +129,7 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--depth` | int | optional | 要取消的分组层级,默认 1最外层) |
| `--depth` | int | optional | 要取消的分组层级,默认 11=最外层,数字越大越内层) |
| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-move`

View File

@@ -1,7 +1,7 @@
# 飞书表格样式与配色规范
> **本文定位**:飞书表格"正确视觉输出"的取值标准与美化决策流——配色、表头、对齐、数值格式、斑马纹、列宽行高、图表展示,以及新增 / 继承 / 美化已有区域三类场景的做法。
> **边界**:本文只讲"样式长什么样、怎么决策"**怎么调用工具写入样式**`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut铁律见 `lark-sheets-core-operations`
> **边界**:本文只讲"样式长什么样、怎么决策"**怎么调用工具写入样式**`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `lark-sheets-write-cells` / `lark-sheets-range-operations` / `lark-sheets-batch-update`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `lark-sheets-conditional-format`。本文不含 shortcut通用编辑准则见主 SKILL.md「飞书表格编辑准则」
## 最高优先级原则
@@ -64,7 +64,7 @@
- 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。
- **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。
- 具体采样与复制流程见下方「场景二:从已有区域继承美化」。
- **列宽调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值):
- **列宽 / 行高调整**(飞书 `+rows-resize / +cols-resize` 按 pixel 传值):
- 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。
- 经验估算:中文每字约 15-18px英文/数字每字约 7-9px外加 10-16px padding。
- 上下限建议 80~400px超上限启用自动换行`word_wrap: auto-wrap`+ 调整行高,而非无限加宽。
@@ -82,7 +82,7 @@
- 包含必要元素:标题、图例、数据标签、坐标轴标题。
- 调整至合适大小,避免数据和标签过多堆叠。
- **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤:
1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`row` 行索引、`col` 列索引如 "A"/"B")、`offset`(锚点内偏移:`row_offset``col_offset`,单位像素)以及 `size``width``height`,单位像素)。
1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`col` 是列字母如 "A"/"B"`row` 是 1-based 行号;以 `+chart-list` 实际返回字段为准)、`offset`(锚点内偏移:`row_offset``col_offset`,单位像素)以及 `size``width``height`,单位像素)。
2. 获取工作表的行高和列宽信息(像素)。
3. 根据每个图表的锚点 `position.row`/`position.col` + 偏移 `offset.row_offset`/`offset.col_offset` + 尺寸 `size.width`/`size.height`,结合行高列宽,计算出每个已有图表覆盖的像素矩形区域 `(x_min, y_min, x_max, y_max)`
4. 为新图表选定大小后,候选放置位置应避开所有已有矩形区域;若存在重叠则向下或向右偏移,直至找到无冲突位置。

View File

@@ -15,7 +15,11 @@
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看工作簿结构 | `+workbook-info` | 获取子表列表、名称、行列数、冻结位置等元数据 |
| 新建工作簿(可预填数据) | `+workbook-create` | 从内存数据建一张新表(`--values` / `--sheets` typed |
| 导入本地文件为新表 | `+workbook-import` | 把本地 `.xlsx` / `.xls` / `.csv` 导入为新的飞书电子表格 |
| 导出工作簿到本地 | `+workbook-export` | 导出为本地 `.xlsx`(整簿)或单子表 `.csv` |
| 变更工作簿结构 | `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | 新建/删除/移动/重命名/复制/隐藏子表、修改标签颜色 |
| 切换子表网格线显隐 | `+sheet-show-gridline` / `+sheet-hide-gridline` | 显示 / 隐藏单个子表的网格线 |
注意:
@@ -65,6 +69,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--index` | int | optional | 插入位置0-based省略时附加到末尾 |
| `--row-count` | int | optional | 初始行数(默认 200上限 50000 |
| `--col-count` | int | optional | 初始列数(默认 20上限 200 |
| `--type` | string | optional | 新子表类型sheet电子表格\| bitable多维表格默认 sheet。bitable 只建空表,内容编辑改用 lark-base 命令 |
### `+sheet-delete`
@@ -87,7 +92,7 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--index` | int | required | 目标位置0-based |
| `--source-index` | int | optional | 源位置0-based可选未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生 |
| `--source-index` | int | optional | 源位置0-basedstandalone 调用时可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生。但在 `+batch-update` 内不可省须显式传——batch 中途无法发起结构查询自动派生 |
### `+sheet-copy`
@@ -138,7 +143,7 @@ _系统`--dry-run`_
| --- | --- | --- | --- |
| `--title` | string | required | 新 spreadsheet 标题 |
| `--folder-token` | string | optional | 目标文件夹 token省略时放在云空间根目录 |
| `--values` | string + File + Stdin简单 JSON | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
| `--values` | string + File + Stdin简单 JSON | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别(日期 / 数字会落成文本,需类型保真改用 --sheets,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
| `--sheets` | string + File + Stdin复合 JSON | optional | 建表后写入的 typed 表格协议 JSON同 +table-put顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/sheets_df.py``df_to_sheet(df, name)` 把 DataFrame 转成一项再包 `{"sheets":[...]}`。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--styles` | string + File + Stdin复合 JSON | optional | 建表时同时写入的视觉处理操作 JSON顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style含 number_format / 颜色 / 对齐 / border_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles`。 |
@@ -184,7 +189,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
**数组项**(类型 object
- `cell_merges` (array<object>?) — 单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
- `col_sizes` (array<object>?) — 列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size each: { range: string, size?: number, type: enum }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size each: { range: string, size?: number, type: enum }
@@ -195,7 +200,13 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
### `+workbook-info`
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`/ `index` / `resource_type` / `row_count` / `column_count` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
> **子表类型 `resource_type`**`sheet`(普通网格子表)/ `bitable`(内嵌的多维表格子表)/ `#UNSUPPORTED_TYPE`(其它暂不支持的嵌入子表)。
> - 网格类操作(读写单元格 / 区域 / 样式 / CSV / 筛选 / 透视 / 图表等)**仅适用于 `sheet`**。对 `bitable` / `#UNSUPPORTED_TYPE` 子表执行网格操作会被直接拒绝并返回明确报错,不再静默出错。
> - 要操作 `bitable` 子表里的数据:该子表条目会附带 `bitable_app_token` + `bitable_table_id` 两个字段,直接用多维表格命令操作,例如 `lark-cli base +record-list --base-token <bitable_app_token> --table-id <bitable_table_id>`(记录增删改查、字段、视图等整套 `lark-cli base` 命令均可用)。不要走 sheets 网格命令。
> - `bitable` / `#UNSUPPORTED_TYPE` 子表条目**只含** `sheet_id` / `sheet_name` / `index` / `resource_type`bitable 另加上述两个 token以及 `is_hidden` / `tab_color`**不输出** `row_count` / `column_count` / `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count` / `frozen_*` 等网格指标(对非网格子表无意义)。
> - tab 管理类操作(`+sheet-rename` / `+sheet-move` / `+sheet-delete` / `+sheet-hide` 等)对任意 `resource_type` 的子表都合法,不受此限制。
### `+workbook-create`
@@ -341,8 +352,17 @@ lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
--title "汇总" --index 0
```
新建一张**多维表格bitable子表**:加 `--type bitable`(默认 `sheet`,即普通电子表格子表)。
```bash
lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
--title "任务表" --type bitable
```
> 💡 `+sheet-create` 只建一张**空子表**。要在已有工作簿里建子表并一步写入 typed 数据和/或样式,用 `+table-put`payload 里命名的子表缺则自动新建)配合它的 `--sheets` / `--styles`,省掉先建表再 `+cells-set` / `+cells-set-style` 的二次往返。
> 💡 `--type bitable` 只建一张**空的多维表格子表**(默认表 + 网格视图 + 默认字段)。它的内容编辑(字段、记录、视图)走 `lark-cli base`:先用 `+workbook-info` 拿到该子表的 `bitable_app_token` + `bitable_table_id`,再用 `lark-cli base +record-list` / `+record-create` 等操作sheets 侧的网格类命令(`+cells-get` / `+cells-set` 等)对 bitable 子表会被拒。
### `+sheet-delete`
> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。

View File

@@ -5,7 +5,7 @@
1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。
2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。
3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get``+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条铁律:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条准则:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
## 新增列 / 新增行的样式继承(防止视觉风格不一致)
@@ -13,7 +13,7 @@
**完整继承清单**(写新列 / 新行时 cells 数组必须同时携带):
1. `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`(字号 / 粗细 / 颜色 / 斜体等)
1. `cell_styles.font_family` / `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`字体名称 / 字号 / 粗细 / 颜色 / 斜体等)
2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment`H-Align / V-Align—— 漏继承会导致新列对齐与原列不一致(常见)
3. `cell_styles.number_format`(小数位 / 千分位 / 百分比 / 日期格式)—— 漏继承会导致同列数值格式混乱
4. `cell_styles.background_color`(背景色)
@@ -43,6 +43,8 @@
**典型反例**:长数字列(如审批单号、流水号)未设 `number_format`,飞书显示为 `1.23E+15`,用户复制出来已经丢失精度。
> **数字还是文本,按"数据本质是量值还是标识符"二选一 —— 不看当下要不要计算**:金额 / 百分比 / 比率 / 计数 / 度量这类**本质是量值**的数据,一律以**数字类型**写入(百分比存小数 `0.54` 配 `number_format:"0%"`**不要**设 `@` 文本格式。**这与"用户当下是否要排序 / 求和"无关**——数据类型由数据本质决定、不由当下用途决定:表格数据几乎总会被后续排序 / 图表 / 二次计算复用,`"54%"` 文本与数值列混排本就破坏一致性,且数字 + `number_format` 显示效果与文本**完全相同**,没有任何理由选文本。**最常见的误判就是"这只是 leaderboard / 报表 / 看板展示,又不用算,写成 `54%` 字符串就行"——这是错的,展示用途不改变"百分比是数值"的事实。**`+table-put` 用 `dtypes` 声明 `int64` / `float64`;版式 `+table-put` 装不下时用 `+cells-set` 传数字 + `number_format`;都别在本地拼成带 `$` / `%` 的字符串走 `+csv-put`。)反过来,编号 `001`、规格 `3-1`、身份证 / 电话 / 单据号等**本质是标识符 / 标签**、要原样保留不被飞书自动解释的内容(否则 `001`→`1`、`3-1`→日期、长号→科学计数),才以**字符串类型**写入(`dtypes` 设 `object`)并把 `number_format` 设为 `"@"`(文本格式),字面保真。
## 使用场景
写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut按数据来源 + 内容形态选:
@@ -54,19 +56,20 @@
| 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut公式 `+csv-put` 也能写) |
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag不触发不必要的值写入 |
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
| 在**已有区域**局部补表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
| **新建子表 / 整表成套美化**(哪怕全是纯文本) | `+table-put --sheets … --styles …` 一步带值 + 全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并payload 里不存在的 sheet 名自动建子表) | `--styles` 与列是否 typed 无关,纯文本同样适用;比「写值 + 多次刷样式」少好几次调用 |
**优先级**:常规批量写入(纯值或公式)优先 `+csv-put`(最短入参,直接传 CSV 文本);含样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(用 typed 协议的 `dtypes` 声明列类型 + `formats` 设展示格式),而不是 `+csv-put`
⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果)。**公式内部含逗号 / 引号 / 换行时必须按 RFC 4180 转义**——含逗号的字段整格用双引号包裹、字段内部的引号再翻倍:如 `=COUNTIF(D5:D22,"及格")` 必须写成 `"=COUNTIF(D5:D22,""及格"")"`(外层双引号包裹整格,内部 `"及格"` 的引号翻倍成 `""及格""`)。漏转义会被 CSV 解析器按逗号拆列、整块写入区域错位(如本该 `G4:H6` 错成 `G4:K4`),详见下方 `+csv-put` 示例。**因此含逗号 / 引号 / 换行的公式优先改用 `+cells-set`JSON 二维数组)写入——`cells[r][c].formula` 字段直接放公式串,零 CSV 转义负担,从根上避免拆列错位**`+table-put` 的 typed 协议只接受 `columns / data / dtypes / formats` 四件套、没有 `formula` 字段,公式写入只能走 `+cells-set` / `+csv-put`)。此外 `+csv-put` **不会**携带样式/批注/图片,也无法把 `=` 开头的内容当字面量文本写入;需要样式/批注/图片用 `+cells-set`(或"写值 + 补样式"两步法)。
⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**:金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put --sheets` 完整 payload外层一定要带 `{"sheets":[...]}`、列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`),数值列用 pandas dtype 串如 `dtypes:{"价格":"float64"}`(百分比同样存小数 `0.305`),并配 `formats:{"价格":"$#,##0.00","完成率":"0.0%"}` 做展示格式,**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**
⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**:金额 / 百分比 / 市值 / 计数等列(判定看数据本质、不看当下用途,见上方"数字还是文本"——"只是报表展示不用算"不改变它是数值),若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put --sheets` 完整 payload外层一定要带 `{"sheets":[...]}`、列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`),数值列用 pandas dtype 串如 `dtypes:{"价格":"float64"}`(百分比同样存小数 `0.305`),并配 `formats:{"价格":"$#,##0.00","完成率":"0.0%"}` 做展示格式,**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,就是走错了路——数值一律以数字本身写入 + 用 `number_format` 控制显示,绝不拼成 `"40%"` / `"$1,234"` 字符串。** 两条正路按版式二选一:① 常规二维表用 `+table-put`(如上,`dtypes` + `formats`);② **版式 `+table-put` 装不下时**(多级 / 合并表头、每方向占两列的宽表,或同一批要混写公式 / 批注 / 富文本)改用 `+cells-set`——`value` 直接传数字(百分比传小数 `0.4`、金额传 `1234.5`),再配 `cell_styles.number_format``"0%"` / `"#,##0.00"`)控制显示,同样显示 `40%` 而数值无损。**版式复杂只是决定用哪个命令,绝不是退回拼 `"40%"` 字符串灌 `+csv-put` 的理由**——`+cells-set` 能承载任意数据类型 + 任意版式
⚠️ 大数据回写走"`+csv-get``--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
## `+cells-set` 写入要点(常用模式 / 公式 / 样式)
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与铁律;选哪个 shortcut 见上方「使用场景」。
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
@@ -80,6 +83,8 @@
- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制”时,**必须**使用模板单元格 + `--copy-to-range`
- 多区域写入相同格式/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制到所有目标区域
⚠️ **模板 `--range` 从数据行起算、别把表头圈进去**`--copy-to-range` 会把 `--range` 模板按目标区尺寸周期性平铺,模板里若含了表头行,表头会每隔几行重复铺进数据区。整列填充时模板只取一格数据样式(如 `H2`),不要取成 `H1:H2`
⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。
💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `lark-sheets-formula-translation` 的规则完成改写,再把最终公式写入 `formula` 字段。
@@ -227,7 +232,7 @@ lark-cli sheets +dropdown-set \
> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet。注意一个坑回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`**必须自己重新补上 sheet 前缀**,否则会被拒。
>
> ⚠️ **sheet 前缀里的表名一律「裸写」,不要加引号**——这条对所有带 sheet 前缀的 range 入参通用(`--source-range`、`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` 的 `--ranges` 等)。即使表名含点或空格(如 `2025.9`、`一月份 `也直接写 `2025.9!A1`**不要**按电子表格习惯写成 `'2025.9'!A1`——引号会被当成表名一部分,导致 `sheet "'2025.9'" not found`
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-set-style` / `+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
@@ -265,6 +270,7 @@ _公共四件套 · 系统:`--dry-run`_
| `--range` | string | required | 目标范围A1 格式,如 `A1:B2` |
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff` |
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000` |
| `--font-family` | string | optional | 字体名称(如 `Arial``微软雅黑` |
| `--font-size` | float64 | optional | 字体大小px10、12、14 |
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic` |
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold` |
@@ -330,7 +336,7 @@ _【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]]
- `value` (oneOf?) — 静态单元格值(文本、数字、布尔)
- `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)'
- `note` (string?) — 单元格批注/备注
- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_size?: number, font_weight?: enum, font_style?: enum, font_line?: enum, …共 10 项 }
- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_family?: string, font_size?: number, font_weight?: enum, font_style?: enum, …共 11 项 }
- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top { top?: object, bottom?: object, left?: object, right?: object }
- `rich_text` (array<object>?) — 富文本内容 each: { type: enum, text: string, style?: object, link?: string, mention_token?: string, …共 17 项 }
- `multiple_values` (array<object>?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string }
@@ -373,7 +379,7 @@ _一个或多个子表的 typed 数据,每个数组元素写入一张子表;
**数组项**(类型 object
- `cell_merges` (array<object>?) — 单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_family?: string, font_line?: enum, …共 13 项 }
- `col_sizes` (array<object>?) — 列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size each: { range: string, size?: number, type: enum }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size each: { range: string, size?: number, type: enum }

35
skills_embed.go Normal file
View File

@@ -0,0 +1,35 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package main
import (
"embed"
"fmt"
"io/fs"
"os"
"github.com/larksuite/cli/cmd"
)
// skillsEmbedFS embeds each skill's agent-readable content (SKILL.md +
// references/, plus lark-whiteboard's routes/ and scenes/) so the CLI serves
// content matching the binary version; machine-resource dirs (assets/, scripts/)
// are excluded, saving ~3.3 MB. It's a whitelist — a new subdirectory type is
// silently omitted until added here.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes
var skillsEmbedFS embed.FS
// init wires the embedded tree in as the default skill content. It compiles into
// `go build .` but not the single-file preview build (`go build ./main.go`), so
// main.go stays self-contained and that build still compiles (shipping no
// embedded skills). Assembly failure warns on stderr rather than panicking.
func init() {
sub, err := fs.Sub(skillsEmbedFS, "skills")
if err != nil {
fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err)
return
}
cmd.SetEmbeddedSkillContent(sub)
}