Compare commits

..

22 Commits

Author SHA1 Message Date
sunpeiyang.996
6ba8ac1c83 docs: remove lark doc workflows
Change-Id: I7d36543f090ebbb42c945667ad25534273a5c305
2026-06-25 14:25:03 +08:00
liujiashu-shiro
1c92ed8841 feat: add im-markdown output for doc fetch (#1550)
* feat: add docs im-markdown fetch format

* refactor: tune docs im-markdown conversion

* test: expand docs im-markdown conversion coverage

* refactor: simplify docs im-markdown handlers

* test: cover docs im-markdown edge cases

* fix: expand doc im markdown tag downgrades

* fix: preserve blockquote paragraph breaks

* fix: handle im markdown nested tables and urls

* docs: document im markdown skill usage

* test: cover doc im markdown fetch

* test: strengthen doc fetch error coverage

* fix: fetch doc skill typo
2026-06-25 11:49:58 +08:00
cl900811
644c3c77dd doc(whiteboard):support export whiteboard as SVG and update whiteboard via SVG (#1559)
* feat(lark-whiteboard): update shortcut, support query or update whiteboard by svg

* feat(whiteboard): pin whiteboard-cli to v0.2.12 in lark-whiteboard skill

* fix(whiteboard): whiteboard shortcuts unit test

* fix(whiteboard): add whiteboard query shortcut unit test
2026-06-25 11:08:16 +08:00
xiongyuanwen-byted
bd898a1d74 feat(sheets): typed table I/O & error contract, workbook import/export, skill refresh (#1355)
* feat(sheets): add +sheet-show-gridline / +sheet-hide-gridline shortcuts

* docs(sheets): strengthen lark-sheets references for common editing pitfalls

Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data

* feat(sheets): implement table-put/table-get and sync skill specs

- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)

* docs(sheets): surface typed-write path at the write-decision point

Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

- split csv-put row to plain-text values (no numeric/date semantics)
- add +table-put row for typed writes into an existing sheet
- add +workbook-create --sheets row for create + typed write in one shot
- add judgment note: number/amount/date/percent/count -> +table-put
  (or +workbook-create --sheets when the workbook does not exist yet);
  plain text -> +csv-put
- reframe write-cells scenario row to lead with numeric semantics
- point new-table writes at +workbook-create --sheets (one shot) instead
  of the create-empty-then-table-put two-step

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).

* docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)

Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.

* docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)

Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.

* feat(sheets): add +workbook-import wrapping the drive import core

Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.

* refactor(sheets): reuse the drive export core in +workbook-export

Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.

* docs(sheets): keep original column widths; align chart axis with requested metric

- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts

* docs(sheets): reword guidance to avoid eval-specific phrasing

Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.

* docs: add lark sheets financial modeling guidance

* docs(sheets): align write-cells reference with the generated output

Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.

* docs(sheets): align +csv-put help with formula support

Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.

* docs(sheets): fix invalid +dim-insert example in chart reference

The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.

* docs(sheets): chart coordinate base / quoting + filter condition enums

Sync three reference-doc corrections from the spec source:

1. chart: label position.row as 0-based (first row = row:0), distinct
   from the 1-based row numbers used by A1 ranges and +dim-insert
   --position, removing the row-base ambiguity.

2. chart: convert the three runnable examples whose JSON contains a
   quoted sheet prefix ('Sheet1'!A1) from inline single-quoted
   --properties '{...}' to a stdin heredoc (--properties - <<'JSON').
   Inside an inline single-quoted string bash strips the inner quotes
   around the sheet name (and splits names with spaces into words),
   corrupting the JSON; a quoted heredoc delimiter performs no shell
   substitution and preserves it. Adds a short note on the pitfall.

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.

* docs(sheets): label +sheet-create --index as 0-based

The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).

* fix(sheets): regenerate flag defs and fix asasalint in table io

* feat(sheets): add counta to chart aggregateType enum

Add `counta` (count non-empty cells, incl. text) to manage_chart_object
dim2.series[].aggregateType in the chart flag schema. `count` only counts
numeric cells, so counting occurrences of a text/category column renders an
empty chart; `counta` enables category frequency counts. Synced from the
sheet-skill-spec canonical schema.

* feat(sheets): make --target-position and --range mutually exclusive on +pivot-create

Both flags map to the same wire field (properties.range), so passing
non-default values for both is ambiguous. Mirror the
--target-sheet-id / --target-sheet-name mutex pattern: --target-position
takes priority over --range, and supplying both with non-default values
is rejected up front with a typed FlagErrorf. --target-position=A1 is
the documented default and is treated as "not set".

Add a symmetric validateCreateInput hook on objectCRUDSpec (alongside
the existing validateUpdateInput), wire it into objectCreateInput, and
inject the pivot-specific check on pivotSpec.

* feat(sheets): rework +workbook-create flags and --styles

- --values builds a type-less typed payload, writing through --sheets' batched set_cell_range path (raw passthrough preserves auto-detect; large tables batch; big ints via json.Number)
- drop --headers (subsumed by --values first row) and --header-style (typed header no longer auto-bold; use --styles instead)
- styles: deep-merge overlapping cell_styles/border_styles fields (was wholesale-replace which dropped fields); add manual border_styles validation (style/weight enums + sides) since --styles is on parseJSONFlagSkip and bypasses the schema validator
- regenerate flag-defs/flag-schemas/skills mirror from sheet-skill-spec (--styles flag + full per-side border schema)

* fix(sheets): add mention_type enum to set_cell_range cells schema

Constrain rich_text mention_type to the proto MENTION_FILE_TYPE set so a
file @mention with an out-of-enum value (e.g. 6 = cloud shared folder) is
rejected by the schema validator before it reaches the server and fails
pb serialization ("mentionFileInfo.fileType: enum value expected").

- data/flag-schemas.json: mention_type gains enum + per-value description
- lark_sheet_write_cells_test.go: cover reject (6) + allow (0 / 2 / 22)

* feat(sheets): implement pandas-split --sheets protocol for +table-put/+table-get/+workbook-create

Synced from sheet-skill-spec canonical (cli:table_put schema +
references). +table-put/+workbook-create accept the new shape via a
tableSheetIn -> tableSheetSpec normalize step (dtype string -> internal
type/format mapping). +table-get emits the same shape so the writer's
df_to_sheet and the reader's sheet_to_df round-trip cleanly.

isoDateToSerial now accepts the full ISO datetime form
(2024-01-15T00:00:00.000, including timezone suffixes) emitted by
df.to_json(date_format="iso"), not just yyyy-mm-dd. End-to-end verified
by the spec repo's contracts/python_helper_roundtrip script against a
real Lark spreadsheet on pandas 2.2 and 3.0.

* feat(sheets): add --dataframe Arrow IPC input for +table-put/+table-get/+workbook-create

Introduce a binary-typed twin of --sheets: --dataframe accepts an Arrow IPC
(Feather v2) payload that pandas' df.to_feather() writes, deriving dtypes and
per-column number formats from the Arrow schema. The two producers are mutually
exclusive and funnel through a shared resolver so +table-put and
+workbook-create stay in lockstep; +table-get gains --dataframe-out for
single-sheet reads. Also auto-grow a sub-sheet's row/column count before
writing so blocks past the backend's default 200x20 bounds no longer fail with
range-exceeds-sheet-bounds.

* docs(lark-sheets): remove financial modeling standards reference

Drop the lark-sheets-financial-modeling-standards.md reference doc and all
pointers to it from SKILL.md, core-operations, and visual-standards. Bump
skill version to 3.0.0.

* docs(lark-sheets): clarify cell-image vs float-image routing and fix reference self-references

Synced from sheet-skill-spec.

- Add a binding-based decision (does the image belong to a record and move with its row?) to route +cells-set-image vs +float-image-create across the SKILL entry, float-image and write-cells references.
- Add routing rows to the SKILL command cheat-sheet and warn against defaulting to float-image out of familiarity.
- Replace mislabeled 本 skill / 子 skill / 跨 skill wording in references with 本文 / reference names, matching the existing convention.

* feat(sheets): add --styles to +table-put for one-step typed write with styling

+table-put now accepts --styles (same shape as +workbook-create's --styles):
cell_styles merge into the set_cell_range matrix, while cell_merges /
row_sizes / col_sizes apply as their own tool calls after the write. The
styles payload is name-matched against the written sheets and validated up
front, so a malformed or mismatched style fails before any write lands.

Also points +sheet-create users to +table-put (auto-creates missing sheets)
when they need data/styles, via a runtime Tip and the lark-sheets skill
references. Flag is sourced from the upstream Base table and regenerated
through sheet-skill-spec (flag-defs.json / flag-schemas.json / gen file).

Adds unit tests (dry-run styles, name-mismatch reject, execute) and a
dry-run E2E (tests/cli_e2e/sheets/sheets_table_put_dryrun_test.go).

* docs(lark-sheets): point read-data to +sheet-info for hidden row/col identification

skip-hidden defaults to false (lossless reads), but the read primitives don't mark which rows/cols are hidden. Cross-reference +sheet-info --include hidden_rows,hidden_cols + row_indices/col_indices so agents can identify hidden ranges when they need to filter or interpret hidden data.

Synced from sheet-skill-spec.

* feat(sheets): document link requirement for @document mentions in cells flag schema

@document mentions (mention_type != 0) must pass link (doc URL) to render a
clickable card; @user mentions (mention_type=0) don't need it. Synced from the
upstream tools-schema.

* fix(sheets): reject cond-format attrs whose shape mismatches rule_type

A conditional-format rule created with --rule-type colorScale but
cellIs-shaped attrs ({compare_type,value}, no color) was accepted by
the CLI and written through to the server, producing a color-less
color-scale segment. That dirty data crashes the frontend on snapshot
deserialization, so the spreadsheet can no longer be opened (5005).

The per-entry schema check can't catch this: properties.attrs.items is
a oneOf over all nine attr shapes and passes as soon as any branch
matches, blind to the sibling rule_type — {compare_type,value} matches
the cellIs branch even when rule_type says colorScale. The tool side
maps attrs blindly by rule_type and only validates dataBar count and
iconSet ordering, so the gap reaches the data layer.

Add a cross-field validator (validateCondFormatAttrs) wired into both
create and update via the new objectCRUDSpec.validateCreateInput hook
(twin of validateUpdateInput). It enforces, per rule_type, the keys
every attrs entry must carry — mirroring the tool's converter contract
— and treats an empty required string (notably color) as missing.
Rule types that take no attrs (duplicateValues / uniqueValues /
containsBlanks / notContainsBlanks) and updates that omit rule_type are
left to the server.

* test(sheets): guard condFormatAttrsRequired against flag-schemas drift

Add TestCondFormatAttrsRequired_MatchesSchemaOneOf, comparing the
hand-maintained condFormatAttrsRequired table against the embedded
flag-schemas.json attrs oneOf (multiset of required-key sets, for both
create and update). The cross-field validator only holds if its
per-rule_type required keys mirror the schema branches, and the two
share no compile-time link — this pins them together so a future schema
sync that adds/drops a required key can't silently desync the table.

* fix(sheets): default +table-get to full used range, not A1 current region

+table-get without --range anchored its current_region probe at A1, so an
internal blank row or column silently truncated everything past it — agents
then treated the partial data as complete (the pro016 / pro025 incident).

- Probe the used range over the full physical grid (row_count × column_count
  from the workbook structure) so it spans internal blank rows/columns; fall
  back to the legacy A1 anchor when dimensions are unknown.
- Emit the actually-read `range` on every sheet so callers can detect
  truncation (get_cell_ranges has no has_more flag).
- Fix the same A1-anchor bug in append mode's last-data-row probe, which could
  otherwise overwrite data past an internal blank row.
- Add unit + dry-run/live E2E coverage; refresh synced skill docs.

* docs(sheets): fix csv-get current_region guidance to cross-check row_count

current_region is a blank-row/column-bounded block, not the true sheet extent:
an internal blank row truncates it, so it can miss rows past the gap. The
read-data reference previously called it the "真实数据边界" and told agents to
prefer it over row_count — which drove the "read only to current_region's last
row, miss the tail" failure.

- current_region: warn it can be both smaller (internal blank rows truncate)
  and larger (trailing summary/signature rows) than the real data range.
- csv-get output contract: clarify its row_count/col_count is the returned size
  (= actual_range), not the physical sheet size; has_more only reflects the
  current range, not whether the whole sheet was read.
- "确定数据范围的正确流程": add a step to cross-check against +workbook-info's
  physical row_count and probe past current_region's last row for data beyond an
  internal blank row.

* fix(sheets): collapse duplicate validateCreateInput from bad merge resolution

A prior merge kept both branches' independently-added validateCreateInput
fields on objectCRUDSpec with conflicting signatures (pivot's
func(rt, input) and cond-format's func(input)), plus both call sites in
objectCreateInput, which failed to compile (validateCreateInput redeclared).

Collapse to the single richer func(rt flagView, input) signature and one
call site. cond-format's validateCondFormatAttrs (func(input), still shared
with validateUpdateInput) is wrapped in a closure that ignores rt. Both
behaviors are preserved: pivot --target-position/--range mutex and
cond-format attrs-shape-vs-rule_type validation.

* refactor(sheets): migrate legacy error helpers to typed errs in sheets domain

golangci-lint forbidigo (errs-no-legacy-helper / errs-no-bare-wrap) flagged
the table I/O, workbook, and dataframe shortcuts that landed on this branch:
93 common.FlagErrorf and 48 fmt.Errorf calls.

- Replace every common.FlagErrorf with common.ValidationErrorf (typed
  *errs.ValidationError, same signature) across workbook / table_io /
  dataframe / object_crud.
- writeDataframeOut's two final --dataframe-out write failures become typed
  errs.NewInternalError(SubtypeFileIO, ...).WithCause(err).
- applyWorkbookCreateVisualOps now passes the typed callTool error through
  unchanged (re-wrapping would downgrade classification) and attaches the
  failing op as a recovery hint only when none is set.
- The remaining fmt.Errorf are genuine intermediate errors that the command
  layer re-wraps into typed validation errors (buildTypedCell / Arrow
  decode-encode) or surfaces as a partial_success message string
  (writeTypedSheets via tablePutPartial); each carries a //nolint:forbidigo
  with that reason, per the lint guidance.

No behavior change: error messages and partial-success shapes are preserved;
gofmt, go vet, golangci-lint (0 issues) and sheets tests all pass.

* fix(shortcuts): clarify single-stdin constraint in flag help and error hint

Input flags advertised '(supports @file, - for stdin)' per flag, leading
AI agents to write '--a - <x --b - <y' where the second '<' silently
clobbers the first and the first flag reads the wrong payload. A process
has a single stdin, so at most one flag per call can use '-'.

- Reword the generated help hint to '- reads stdin (one flag per call;
  use @file for others)'.
- Add an actionable .WithHint to the stdin-conflict validation error
  pointing callers to @file for the extra flags.
- Assert the new hint in TestResolveInputFlags_DuplicateStdin.

* feat(sheets): +cells-get/+csv-get --max-chars 默认值 200000 → 500000

放宽默认防爆上限。flag_defs_gen.go 由 go generate 重生;flag_defs_test.go
的 expected default 同步;flag-schemas.json schema_version 2 → 3 是上游
spec-tables 架构调整带来的元数据 bump,与本业务改动无关、go:embed 不解析
该字段、无功能影响。

Synced from sheet-skill-spec@93f7a78.

* docs(lark-sheets): sync from spec — +csv-put 含逗号公式正例 + 收敛警示标签

源同步自 sheet-skill-spec:write-cells 补含逗号公式 RFC 4180 转义正例与结构化写入优先指引;全 reference 收敛「高频致命错误」类标签。

* docs(lark-sheets): sync from spec — --max-chars 放出为可见 flag + 落盘优先指引

源同步自 sheet-skill-spec:--max-chars 放出(默认 500000,可调小避免大输出被 Bash/终端转存为文件、改 has_more 分页);read-data 增「大数据优先落盘」指引。

* feat(sheets): 写操作报错增强 + --token 别名

- 复合 JSON shape 校验失败时报错附 --print-schema 提示,agent 可直接拿到精确结构(pro26 头号:+cells-set --cells 反复猜 shape)
- JSON 解析失败且该 flag 支持 stdin 时提示改用 stdin(公式/引号/逗号内联到 shell 被转义弄坏 JSON)
- --token 作为 --spreadsheet-token 的解析期别名:复用 sheets 已有 PostMount 钩子 + pflag normalize,仅 sheets 包,common 零改动

* docs(lark-sheets): sync from spec — set+H 改单引号 / 速查表补臆造命令名 / workbook-import 引导

* fix(sheets): migrate +table-put to typed error contract

The merge from main brought in #1449 (retire legacy error envelopes),
which removed output.ExitError / output.ErrDetail and forbids
constructing them. Port tablePutPartial off the legacy envelope:

- no sheets written -> typed errs.APIError (plain failure)
- some sheets written -> ok:false result via runtime.OutPartialFailure
  carrying written_sheets, returning the partial-failure exit signal

Also fix two drifts the same merge introduced:
- regenerate flag_defs_gen.go to match the committed flag-defs.json
- update the --max-chars flag test to assert visible (no longer hidden)

* docs(lark-sheets): sync from spec — set+H 告诫通则化(移入 stdin 段)

* feat(sheets): styles 接受 halign/valign 等对齐字段别名

把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。

* feat(sheets): resolve wiki URLs to the backing spreadsheet for --url

Sheets shortcuts only accepted /sheets/ and /spreadsheets/ URLs via --url.
A /wiki/<node_token> URL was rejected with "must be a spreadsheet URL"
because the wiki node_token is not a spreadsheet token: resolving it to the
backing spreadsheet needs a wiki get_node call, which Validate/DryRun (kept
network-free) must not make.

Mirror the existing slides/doc/drive two-stage pattern:

- parseSpreadsheetRef classifies --url / --spreadsheet-token network-free
  into a sheet token or an (unresolved) wiki node_token.
- resolveSpreadsheetTokenExec (Execute only) resolves a /wiki/ node_token
  via wiki get_node, verifies obj_type=sheet, and returns the obj_token.
  The wiki:node:read scope is enforced on this path only, so non-wiki
  invocations are unaffected.
- resolveSpreadsheetToken stays network-free for Validate/DryRun, passing
  the node_token through unchanged.

All 47 Execute paths (including +batch-update and +workbook-export) switch
to the Exec resolver; Validate/DryRun keep the network-free one. No tool
schema change: the CLI feeds the resolved spreadsheet token as excel_id, so
this is a pure CLI-layer change.

Tested: unit (parse classification + wiki get_node e2e via httpmock) and
live end-to-end against a real wiki spreadsheet (read: +workbook-info,
+cells-get, +csv-get; write: +sheet-create, +sheet-rename, +csv-put).

* docs(sheets): note --url accepts wiki URLs (synced from spec)

* fix(sheets): match --url path segment via url.Parse, not substring

parseSpreadsheetRef classified /wiki/ with strings.Index over the whole URL, so a /sheets/ link whose query or fragment merely contained /wiki/ (e.g. .../sheets/sht?from=/wiki/x) was hijacked into a get_node call. Now parse the URL and match /sheets/, /spreadsheets/, /wiki/ only as a path prefix, mirroring slides parsePresentationRef which already fixed this class. Drop the substring helpers. Also align wiki resolution with slides: CallAPITyped (typed error + log_id) and classify an incomplete get_node response as InternalError instead of a --url validation error. Add regression tests for query/fragment /wiki/ and incomplete node.

* fix(sheets): satisfy errorlint/copyloopvar + regen flag defs

- helpers_test.go: drop the Go 1.22+ redundant `tc := tc` loop copy
  (copyloopvar).
- lark_sheet_dataframe.go, lark_sheet_table_io.go: switch the
  intermediate-error fmt.Errorf calls from %v to %w so errorlint passes.
  Behavior unchanged — these errors are always rewrapped into typed
  validation errors at the command layer.
- flag_defs_gen.go: regenerate from data/flag-defs.json (drift from the
  wiki-URL merge).

* ci: allow Apache Arrow module in license check

Arrow is Apache-2.0 overall, but it vendors c-ares (LicenseRef-C-Ares,
ISC-like) inside the module which go-licenses classifies as Unknown and
the strict disallowed_types=...,unknown gate rejects.

Pass --ignore github.com/apache/arrow/go/v17 since Arrow is required by
sheets +table-put / +table-get / +workbook-create --dataframe (Arrow IPC
ingest) and the vendored c-ares is not redistributed by us.

* fix(sheets): resolve wiki URL in +range-move/+range-copy Execute

transformExecuteFn (the named Execute helper shared by +range-move and +range-copy) still called the network-free resolveSpreadsheetToken, so a /wiki/ URL reached transform_range as an unresolved node_token and failed. #1519's sweep over Execute hooks only rewrote inline closures; this is the only Execute backed by a named helper. Switch it to resolveSpreadsheetTokenExec (Validate/DryRun stay network-free) and add a +range-move wiki-URL regression test.

* refactor(sheets): drop +table-put manual capacity grow; rely on set_cell_range auto-grow

set_cell_range now auto-grows the sub-sheet to fit the write, so the
ensureSheetCapacity helper (and its modify_sheet_structure dim-insert
call before each write) is no longer needed. This also closes a data-
safety hole flagged in review: inserting before the last existing row
could push real data down into the area set_cell_range was about to
write, and allow_overwrite=false could not protect against it because
the structural insert had already mutated the sheet by the time the
write-collision check ran.

Verified end-to-end against a real spreadsheet: +table-put writing
300x25 into a fresh Sheet1 (default 200x20) succeeds in one write and
the sheet ends up 301x25.

* fix(sheets): close --dataframe stdin guard hole

--dataframe is binary and bypasses the common Input resolver, which is
where the existing single-stdin guard lives. Result: an invocation like
+table-put --dataframe - --styles - was accepted, then one of the two
consumers raced for stdin and the other silently saw an empty stream.

Add a stdinConsumed marker on RuntimeContext that both consumers share:
common.resolveInputFlags sets it when an Input flag uses '-', and
readDataframeBytes both checks and sets it. A second consumer is
rejected up front with an actionable hint pointing at @file.

Flagged in code review (lark_sheet_dataframe.go:93).

* fix(sheets): harden +table-put / +table-get input validation and round-trip safety

Four review-flagged correctness gaps in table I/O, all bundled because
they touch the same file:

1. --sheets accepted trailing data after the first JSON value
   (json.Decoder does not surface that, unlike json.Unmarshal). A new
   decoderExpectEOF helper rejects e.g. `--sheets '{...} oops'` with a
   typed validation error instead of letting the leading object pass
   through and surface as a confusing downstream failure.

2. +table-get with a duplicate header (e.g. `amount, amount`) used to
   read back successfully — the dtypes map silently collapsed to one
   entry — and only failed later on +table-put because the writer
   rejects duplicate column names. Fail fast at read time with an
   actionable hint to rename or pass --no-header. --no-header mode is
   exempt (fallback col<N> names are always unique).

3. +table-put dry-run rendered an invalid range like A1:C0 when
   header=false with rows=[]. tablePutFullRange returns "" for an
   empty matrix or zero columns instead of building a degenerate
   rectangle.

4. +table-get with --sheet-id and a get_workbook_structure miss (read
   failure or selector mismatch) used to return a target with
   name="", which then broke +table-get → +table-put round-trip (the
   writer requires a non-empty sheet name). Fall back to using the id
   as the name.

End-to-end verified against a real spreadsheet: trailing data, duplicate
header, and --no-header fallback all behave as advertised.

* fix(sheets): apply +workbook-create style-only ops instead of silently dropping them

A +workbook-create call carrying only cell_merges / row_sizes / col_sizes
(no --values / --sheets and no cell_styles) used to create the workbook
but silently drop the requested visual ops. Two reasons, both fixed:

- workbookCreateStyleDimensions only counted cell_styles when computing
  the write extent, so cell_merges / row_sizes / col_sizes always
  contributed 0 → buildValuesPayload returned a nil payload → Execute
  skipped writeTypedSheets entirely → no visual ops ran. Extend the
  helper to fold the merge / resize ranges in.

- Pure row_sizes / col_sizes payloads can never expand a cell rectangle
  (they are dimension ranges, not cell ranges), so even with the extent
  fix Execute would still skip the write path. Add a no-data branch:
  when payload == nil but a styles item is present, look up the default
  sheet and apply visual ops directly via applyWorkbookCreateVisualOps.
  The dry-run plan mirrors this so the preview shows the visual ops.

Also picks up the --values trailing-JSON-data EOF check (mirror of the
--sheets one in lark_sheet_table_io.go).

End-to-end verified against a real spreadsheet: a cell_merges-only
+workbook-create now produces a sheet with merged_cells_count: 1.

* fix(sheets): preserve causes and render messages cleanly for typed validation errors

common.ValidationErrorf goes through fmt.Sprintf, which does not support
%w — the seven call sites that used `%w` were rendering the cause as
literal `%!w(*fmt.wrapError=&{...})` and dropping the cause from the
typed-error chain (so callers couldn't errors.As back to the underlying
error).

Switch each to `%v` for clean rendering and attach the cause via
.WithCause(err) so the typed contract is preserved. Touched call sites:

- lark_sheet_dataframe.go: --dataframe Arrow decode / stdin read / file
  read failures (3 call sites).
- lark_sheet_table_io.go: --sheets invalid JSON, payload-validate
  per-cell coercion error, buildSheetMatrix per-cell error,
  --dataframe-out arrow encode failure (4 call sites).

End-to-end verified against a real spreadsheet: both invalid-JSON and
typed-cell errors now render readable messages instead of %!w(...).

* sync(sheets): pick up +sheet-{show,hide}-gridline in +batch-update schema

Mirror of the sheet-skill-spec change adding the two gridline shortcuts
to cli-schemas.json batch_update.operations.shortcut enum. Synced from
the upstream canonical via generate:cli + sync:cli.

Verified end-to-end on a real spreadsheet — +batch-update with a
+sheet-hide-gridline op passes schema validation and the backend run
returns succeeded: 1.

* sync(sheets): pick up +workbook-export UX clarification from spec

Mirror of the sheet-skill-spec update that documents +workbook-export's
default-no-download behavior and its relationship to drive +export
--doc-type sheet. Synced from canonical via generate:cli + sync:cli +
go generate.

End-to-end verified against a real spreadsheet:
- Omit --output-path → ok:true, downloaded:false, file_token returned
- Pass --output-path ./crfix_test.xlsx → ok:true, file saved
  (17892 bytes), saved_path returned

The --help output for +workbook-export now states the default behavior
and points callers at `drive +export --doc-type sheet` when they need
the --output-dir / --file-name / --overwrite split.

* test(sheets): assert typed errs.Problem instead of err.Error() substrings

Per the coding guideline "Error-path tests must assert typed metadata via
errs.ProblemOf (category / subtype / param) and cause preservation, not
message substrings alone." — sweep through every error-path assertion in
the sheets domain and replace the
`strings.Contains(stdout+stderr+err.Error(), ...)` pattern with two
small helpers landed in helpers_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

~60 assertion sites across 18 test files now check the typed envelope
shape, with message-substring checks moved onto the returned Problem
(.Message / .Hint / .Param). The substring is preserved as a sanity
check rather than the sole assertion, so a category drift like
validation → internal would now fail loudly instead of slipping past.

Cases intentionally left as substring (each with a one-line reason):
  - Errors that come straight from cobra's native flag parser (untyped
    *errors.errorString — e.g. "required flag(s) ... not set", mutually-
    exclusive groups). Re-typing these needs a custom FlagErrorFunc and
    is out of scope here.
  - Intermediate errors from decodeArrowToSheet that the caller wraps
    into a typed envelope (`//nolint:forbidigo` reason). Those unit
    tests assert the unwrapped intermediate directly.

One production tweak:
  - shortcuts/sheets/flag_schema.go: printFlagSchemaFor returns typed
    *errs.ValidationError (with WithParam("--flag-name") on the
    unknown-flag branch) instead of raw fmt.Errorf. The framework
    already wraps this when called via --print-schema, so user-facing
    behaviour is unchanged; direct callers (and tests) now get the
    typed envelope.

Verified: go test ./shortcuts/sheets/... passes; golangci-lint
--new-from-rev=origin/main reports 0 issues.

* test(common): assert typed errs.Problem instead of err.Error() substrings

Mirror of the sweep just landed in shortcuts/sheets: replace error-path
substring assertions with typed-envelope checks via two small helpers
landed in a new shortcuts/common/typed_error_assertions_test.go:

  requireProblem(t, err, wantCategory, wantSubtype, msgContains)
    -> *errs.Problem
  requireValidation(t, err, msgContains)
    -> *errs.ValidationError   // shorthand for CategoryValidation +
                               //   SubtypeInvalidArgument; lets callers
                               //   also assert .Param / .Params / .Cause

8 sites moved to typed assertions across runner_jq_test.go,
mcp_client_test.go, drive_media_upload_typed_test.go, and
runner_input_test.go (the input tests already used a typed-param helper;
this just retargets the substring follow-up onto the typed Message).

Sites intentionally left as substring + comment (production returns raw
fmt.Errorf, not a typed envelope):
  - runner_botinfo_test.go (6 sites): BotInfo / fetchBotInfo wrap upstream
    errors with fmt.Errorf so the SDK-level message ([99991], 403,
    invalid character, etc.) shows through.
  - runner_args_test.go (4 sites in 2 tests): rejectPositionalArgs returns
    raw fmt.Errorf to satisfy cobra's PositionalArgs contract.
  - permission_grant_test.go (2 sites): assert on stderr / hint strings,
    not error messages — already out of the err.Error() substring class.

No production code changes.

Verified: go test ./shortcuts/common/... passes;
golangci-lint --new-from-rev=origin/main ./shortcuts/common/... reports
0 issues.

* fix(sheets): plug four +table-put / +table-get correctness gaps flagged in CR

Four review-flagged bugs, all in lark_sheet_table_io.go (bundled because
they touch the same file and the same +table-put / +table-get domain):

1. +table-get --dry-run dropped the --sheet-id / --sheet-name selector
   from the get_cell_ranges body, while Execute always passed it. Agents
   that validate the dry-run shape and then run live would see a request
   shape mismatch. The dry-run now calls sheetSelectorForToolInput so
   the body matches Execute.

2. isDateNumberFormat used a simple `strings.ContainsRune(_, 'y')` so
   number formats like "JPY #,##0" (a currency prefix that happens to
   contain a lone 'Y') were misread as date formats — round-tripping
   integer cells out as ISO dates. The detector is now token-aware:
   it skips quoted "...", `\\x`-escaped, and `[...]` bracket sections,
   and only fires on an unescaped `yy` (a real Excel year token).

3. sheetCreateDims sized new append-mode sheets by `headerOn(s)` only,
   but writeSheetData forces a header on empty append sheets when
   Header == nil. Near 50000 rows / 200 cols this created the sheet one
   row short and the follow-up set_cell_range bounced off the backend
   ceiling. Size now matches the forced-header logic exactly.

4. tableGetTargets fallback paths (read-failure / selector mismatch on
   --sheet-id) returned a target with name="" — already corrected for
   --sheet-id structure-success path in 086876d2, but the structure-
   failure fallback still left it empty. Use the id as the name there
   too so the +table-get → +table-put round-trip never breaks on a
   nameless sheet.

End-to-end verified against a real spreadsheet:
- table-get --dry-run with --sheet-name / --sheet-id both render the
  selector field in the get_cell_ranges body
- A real round-trip (typed put → get) preserves dtypes + formats

* fix(sheets): bound --dataframe memory use with byte / row / column caps

readDataframeBytes used to read the whole Arrow file unbounded — a
stdin / file > 1 GiB would OOM the CLI long before the backend
per-sheet ceilings kicked in. decodeArrowToSheet then materialized
every record into [][]interface{} regardless of size.

Three caps now match the backend's per-sheet hard ceilings:
- byte cap: 256 MiB (covers worst-case 200×50000 cells × ~25 B Arrow
  overhead). File path pre-Stat()s before opening; both file and stdin
  paths read through io.LimitReader so an oversized input is rejected
  without allocating the full payload.
- column cap: 200, checked at schema-decode time before allocating any
  per-column slices.
- row cap: 50000, checked during record-batch iteration so a 1M-row
  Arrow file is rejected mid-stream instead of fully decoding first.

End-to-end verified against PPE — a 257 MiB file is rejected at file-
Stat with a typed validation error before any read happens.

* fix(drive): wrap +export ctx cancellation/deadline as typed errs.NetworkError

The poll loop in RunExport returned ctx.Err() directly in two places —
on the inter-attempt sleep cancel and on the pre-attempt deadline check.
That let context.Canceled / context.DeadlineExceeded escape as untyped
errors at the cobra layer, bypassing the typed-error contract every
other failure path already honors.

Add wrapExportContextErr that maps both into errs.NewNetworkError with
SubtypeNetworkTransport / SubtypeNetworkTimeout respectively and
preserves the cause via .WithCause(err), so callers can still
errors.Is(err, context.Canceled) downstream.

CR-flagged at drive_export.go:229 / :234.

* ci(license): narrow Apache Arrow workaround with a follow-up assertion

The dependency-license check still has to --ignore Apache Arrow wholesale
because go-licenses' classifier parses its LICENSE.txt as a single license
and mis-reports the module as LicenseRef-C-Ares / Unknown (Arrow inlines
the c-ares 3rdparty notice alongside its own Apache-2.0). Re-classifying
on our side isn't possible without changing go-licenses itself.

The CR concern was that --ignore is too wide — a future Arrow re-license
or new inlined dep would silently sail through. Add a follow-up step that
re-checks Arrow's LICENSE.txt independently: it must still open with
"Apache License" AND must still inline the c-ares 3rdparty notice (the
two facts that make the --ignore safe today). If either invariant breaks,
CI fails here and forces a human to re-evaluate the ignore.

Verified locally — both assertions pass against the current pinned
Arrow v17.

* sync(sheets): pick up +table-put payload-shape doc corrections from spec

Mirror of the sheet-skill-spec change that fixes three places teaching
an invalid +table-put payload shape — the typed protocol only has
columns / data / dtypes / formats (no formula field) and must always
be wrapped in an outer {"sheets":[...]} envelope. write-cells and the
SKILL.md decision table previously used the wrong field names (type /
format) and pointed users at +table-put for formula writes, which the
shortcut can't actually accept.

Synced from upstream canonical via generate:cli + sync:cli.

* test(sheets/e2e): add E2E coverage for new shortcuts + typed workbook-create

AGENTS.md requires a dry-run E2E for every new shortcut and a live E2E
for new flows. Three new files cover the four shortcuts this branch
adds or materially changes:

- sheets_gridline_dryrun_test.go — pins +sheet-show-gridline /
  +sheet-hide-gridline as a single modify_workbook_structure call with
  the right operation name (show_gridline / hide_gridline) and
  sheet_id, so an op-name typo would trip CI before any live run.

- sheets_workbook_import_dryrun_test.go — pins +workbook-import as a
  two-step plan (drive media upload + drive import-task create) with
  the doc type hard-coded to "sheet" — the wrapper's whole reason for
  existing on top of generic drive +import. --name reaches file_name
  on the wire; file_extension is sniffed from the local file.

- sheets_table_put_typed_workflow_test.go — two live workflows running
  against a freshly created spreadsheet. The first runs the full
  typed +table-put → +table-get round-trip (date / numeric / object
  columns with custom number_format) and asserts the dtype + format
  contract holds end-to-end. The second exercises the typed
  +workbook-create --sheets path: create + write in one shortcut, the
  payload sheet name adopts the workbook's default sheet (no empty
  "Sheet1" left behind), and the typed contract still survives the
  read-back.

End-to-end verified locally (user identity): typed put round-trips
preserve dtypes (date → datetime64[ns], numeric → float64, object →
object) + formats verbatim; workbook-create adopts the named sheet as
the first sheet with the same typed shape intact.

* sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec

Mirror of the sheet-skill-spec change that adds a DataFrame ↔ JSON
bridge as a skill-bundled Python script instead of inside the CLI
binary. Per PR #1355 review (docx NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, §4.2):
keep the CLI a thin JSON/REST client; pandas / Arrow editing lives in
the caller's Python process. Synced from canonical via generate:cli +
sync:cli.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one sheet, .parquet / .feather / .arrow / .csv / .json. Shells out to
  `+table-put` / `+table-get` over typed JSON — no CLI changes.
- SKILL.md decision tree + write-cells.md +table-put section: explicit
  pointers so pandas users land on the script instead of hand-rolling
  the `--sheets` payload.

End-to-end verified against PPE: 3-row DataFrame (datetime / float /
object) round-trips parquet → script put → real sheet → script get →
parquet with dtypes preserved.

* Revert "sync(sheets): pick up sheets_df.py — pandas ↔ JSON skill script from spec"

This reverts commit 2964983b92.

* sync(sheets): pick up sheets_df.py + doc DRY cleanup from spec

Mirror of the sheet-skill-spec change that ships a 32-line helper-only
sheets_df.py (df_to_sheet + sheet_to_df) and removes the corresponding
inline `def` blocks from three reference docs.

- skills/lark-sheets/scripts/sheets_df.py (new): pandas DataFrame ↔
  one +table-put / +table-get sheet, importable as a library. Same
  helper pair the docs already taught, lifted out of the prose so
  callers can `from sheets_df import df_to_sheet, sheet_to_df`.
- lark-sheets-write-cells.md / lark-sheets-read-data.md /
  lark-sheets-workbook.md: drop the inline helper definitions; keep
  the usage examples (single/multi-sheet, round-trip) and switch them
  to import-from-script. workbook reference's +workbook-create
  --sheets section now points pandas users at the helper directly
  (was previously a textual reference back to write-cells).

End-to-end verified against PPE (--as user):
- +workbook-create with df_to_sheet for three sheets (income / balance
  / cashflow): create ok, dtypes (datetime64[ns] / float64) + formats
  (#,##0 / 0.0% / yyyy-mm-dd) survive on read-back through sheet_to_df.
- read → pandas mutate → write-back round-trip preserves both data
  and formats.

* chore: drop accidentally-committed __pycache__/ and gitignore .pyc

The previous commit (5fac9c39) shipped sheets_df.py and inadvertently
included its `__pycache__/sheets_df.cpython-312.pyc` — local Python
import created the bytecode cache during PPE round-trip verification and
`git add skills/lark-sheets/` swept it in.

Remove the pyc and add Python bytecode patterns to .gitignore so the
skill-bundled helper scripts don't pull cache files into future commits.

* refactor(sheets): drop --dataframe / --dataframe-out + apache/arrow dep

Per the design review at NcmxdRo2yoZ4OXxoMUZcxRZ7nHd, the Arrow IPC binary
input/output channel adds a heavy columnar runtime to the CLI for no new
capability — the typed JSON --sheets path already covers everything, and
the column-major / zero-copy advantages collapse the moment the CLI re-
encodes into the row-oriented sheets OpenAPI JSON body. Removing it also
lets us drop the `--ignore github.com/apache/arrow/go/v17` license-check
escape hatch.

Deleted:
- shortcuts/sheets/lark_sheet_dataframe.go (+ test)
- --dataframe branches in +table-put / +workbook-create
- --dataframe-out branch in +table-get
- StdinConsumed / MarkStdinConsumed exported methods (the binary stdin
  reader was the only out-of-band consumer); internal stdinConsumed
  guard against duplicate `-` input flags stays
- apache/arrow/go/v17 + transitive deps via `go mod tidy`
- CI go-licenses --ignore for arrow and the LICENSE.txt assertion step
- --dataframe / --dataframe-out coverage in skill references

Pandas users keep the round-trip via the existing skill script
skills/lark-sheets/scripts/sheets_df.py over the JSON path.

The full pre-removal state is preserved on branch feat/sheets-arrow-stash.

Upstream sheet-skill-spec follow-up: the two flag rows in the canonical
spec + base table tblV2F6fqIjyCFQW must also be dropped so the next sync
does not re-add them.

* sync(sheets): pick up --sheets one-liner fix from spec

Mirrors sheet-skill-spec 5562f83. The +table-put / +workbook-create
--sheets flag descriptions (and the --print-schema description on the
sheets array) now point at the existing df_to_sheet helper instead of
the previous misleading one-liner that produced a dict missing the
outer {"sheets":[...]} envelope and the per-sheet `name`. Agents that
copy-paste the description verbatim now build a valid payload.

Auto-synced via spec's generate:cli + sync:consumers; go generate
./shortcuts/sheets/... regenerated flag_defs_gen.go so its embedded
flagDefs stays byte-equal to data/flag-defs.json.

* test(sheets/e2e): close E2E coverage gaps for newly added shortcuts

AGENTS.md requires both dry-run and live E2E for every newly registered
shortcut, and behavior-changing refactors need at least the matching
half. Three gaps remained on feat/lark-sheets-develop:

- +sheet-show-gridline / +sheet-hide-gridline (new): only dry-run E2E.
  Add sheets_gridline_workflow_test.go — create a real spreadsheet,
  toggle hide then show against a live sub-sheet, assert ok=true on
  both (gridline state is write-only — there is no read-back field on
  +sheet-info / +workbook-info — so a successful envelope is the
  meaningful signal; the dry-run E2E already pins the wire shape).

- +workbook-import (new): only dry-run E2E. Add
  sheets_workbook_import_workflow_test.go — write a local CSV, run
  the full upload → create-task → poll, assert ready=true with a
  sheet token, +info confirms the imported workbook is reachable,
  cleanup deletes the spreadsheet.

- +workbook-export refactor (no-download default changed): had live
  E2E but no dry-run E2E in tests/cli_e2e/. Add
  sheets_workbook_export_dryrun_test.go — pin the three sheet-
  specific differences vs drive +export: type=sheet hard-coded,
  csv mode routes --sheet-id onto sub_id (xlsx mode omits it), and
  --output-path maps onto the dry-run plan's top-level output_dir.
  Also pins the csv-without-sheet-id validation error.

* refactor(sheets): unify workbookCreatedButFillFailed with OutPartialFailure

Three "made it halfway and stopped" exits in the sheets domain previously
disagreed on shape, which made the post-failure recovery flow hard for
agents to predict from one command to another:

- +table-put partial write           → exit 1, stdout ok:false envelope
- +table-put zero-sheet write        → exit 1, stderr api/server_error
- +workbook-create create-but-fill   → exit 2, stderr validation/failed_precondition

OutPartialFailure exists exactly for "the side effect landed but the
follow-up didn't" — it stamps an ok:false result envelope on stdout
(carrying the state the caller needs to recover) and returns the bare
partial-failure exit signal. The workbook-create fill-failure path was
the odd one out: it surfaced as a typed failed_precondition error on
stderr, which agents couldn't tell apart from a plain validation refusal
even though the spreadsheet really did exist and a retry / cleanup was
possible.

Migrate workbookCreatedButFillFailed onto OutPartialFailure so the four
call sites in +workbook-create's Execute (sheet-resolve failure, initial
fill failure, style-only resolve failure, style-only apply failure) emit
the same envelope shape +table-put's partial write does:

  {
    "ok": false,
    "data": {
      "spreadsheet_token": "shtNEW",
      "reason": "spreadsheet shtNEW created but initial fill failed",
      "hint":   "the spreadsheet exists; retry the fill … or delete it",
      "cause":  {"category": "...", "subtype": "...", "message": "..."}
    }
  }

The inner failure's typed problem (category / subtype / message) is
flattened into the `cause` field so agents stay diagnosable from the JSON
envelope alone, instead of having to errors.Unwrap a Go error.

Updated TestExecute_WorkbookCreate_FillFailureKeepsToken to assert the
new shape (ok:false envelope on stdout, *output.PartialFailureError exit
signal, structured cause carrying the underlying invalid_response
subtype) — preserving the original test intent (token must survive for
recovery; inner cause must stay diagnosable) under the new contract.

* chore(sheets): three review nits — WithCause + stale comment + unexport

- shortcuts/sheets/flag_schema_validate.go:106 — composite-JSON shape
  validation was wrapping vErr's message into a typed sheets validation
  error without preserving vErr as the typed cause; add the missing
  .WithCause(vErr) so errors.Unwrap and ProblemOf still find the
  underlying validator error (matches every other typed-error chain
  helper in the file).

- shortcuts/sheets/lark_sheet_batch_update.go:92 — comment claimed
  batchUpdateInput returns "FlagErrorf-typed errors", but FlagErrorf no
  longer exists (the typed-error migration replaced it with
  common.ValidationErrorf / errs.ValidationError); update the comment
  to reflect what is actually returned.

- shortcuts/drive/drive_export.go:121 — drop the ValidateExport public
  alias and rename to validateExport. sheets +workbook-export reuses
  RunExport / PlanExportDryRun from this package but inlines its own
  (sheet-specific) Validate, so there is no cross-package call site —
  ValidateExport was a misleading sibling of the genuinely-shared
  ValidateImport. Comment added to record the asymmetry so future
  readers do not export it back.

* chore(deps): drop stale indirect bumps left by the arrow removal

The earlier --dataframe / --dataframe-out + apache/arrow/go/v17 removal
deleted the arrow consumer but left two indirect lines in go.mod pinned
to the versions arrow had pulled in:

  - github.com/kr/text                   v0.2.0
  - golang.org/x/exp  v0.0.0-20240222234643-814bf88cf225

With arrow gone, larksuite/cli was the only requirer of those exact
versions; every real consumer needs lower ones (kr/pretty wants
kr/text v0.1.0; charmbracelet/huh wants x/exp …20231006; xo/terminfo
wants x/exp …20220909). Removing the two indirect lines and running
`go mod tidy` lets MVS pick the real-consumer versions and drops the
explicit indirect entries entirely — go.mod net-diff against main is
now zero for this branch.

Verified locally: go build ./...; go test ./shortcuts/sheets/...
./shortcuts/drive/... ./shortcuts/common/... ./internal/auth/...
./cmd/auth/... — all green.

---------

Co-authored-by: zhengzhijie <zhengzhijie.j@bytedance.com>
Co-authored-by: Chenweifeng-bd <chenweifeng.1534@bytedance.com>
2026-06-25 10:48:13 +08:00
ZEden0
898e6d4b3b fix: prefix docs resource shortcuts (#1564) 2026-06-24 22:39:57 +08:00
zgz2048
7df37ed715 feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts

* docs: clarify base coordinate resolution

* fix(base): address resolve shortcut ci

* fix(base): format resolved record share hint

* fix(base): simplify record share hint data

* fix(base): use field ids in resolved record data

* fix(base): guide record share resolve to update record

* fix(base): include record upsert example in resolve hint

* fix(base): reject add-record urls in resolver

* fix(base): validate title resolve query length

* fix(base): hide resolve alias flags from help

* fix(base): prefer title flag for title resolve

* docs(base): clarify token resolution wording
2026-06-24 22:26:29 +08:00
91-enjoy
3f9ace8af5 feat: support card action trigger (#1528)
Add support for card.action.trigger, the event fired when a user interacts with an
interactive card (button click, form submit, dropdown, checkbox, input, date picker, etc.).
The handler flattens the V2 envelope into a structured output and auto-fetches the original
card content (card_content) at consume time, enabling a complete read-then-update workflow
without extra API calls.
2026-06-24 22:00:08 +08:00
Zhang-986
b3514e5519 fix(binding): skip unix mode audit on windows (#1525) 2026-06-24 20:54:24 +08:00
ILUO
b46e60c156 feat: add task event consumer (#1510)
* feat: add task event consumer

* fix: address task event review feedback

* feat: remove legacy task event subscription shortcut

* test: strengthen task preconsume error assertions
2026-06-24 17:32:02 +08:00
chenxingtong-bytedance
d71bab0061 docs(im): clarify audio message opus requirement (#1271)
Fix IM shortcut behavior for audio messages to match the Feishu/Lark file upload API: --audio is for voice messages and supports only Opus audio. Non-Opus local/URL inputs such as mp3 and wav are now rejected before upload with an actionable typed validation error. Users can still send those files as attachments with --file
2026-06-24 14:41:54 +08:00
liangshuo-1
d11a6e97a4 chore: release v1.0.57 (#1553) 2026-06-23 20:43:41 +08:00
raistlin042
e4248d1154 fix: harden lark-apps +init/+html-publish and skill guidance (#1517)
* fix: reject +init into a different app's project directory

* fix: reject single HTML files larger than 10MB in +html-publish

* docs: clarify publish visibility, domain routing, and role/permission boundary
2026-06-23 20:18:10 +08:00
fangshuyu-768
cb54bea00d docs(lark-doc): refine rich block, path, and block ID guidance (#1508) 2026-06-23 18:27:36 +08:00
hanshaoshuai
036e5799d3 fix(ci): bind semantic review to workflow run head 2026-06-23 18:21:29 +08:00
xukuncx
c4106f50b2 fix(mail): resolve folder/label filter once per +triage list call (#1512)
buildListParams used to re-call resolveFolderID / resolveFolderName (and the
label counterparts) on every list page to assemble folder_id / label_id.
Because resolveListFilter already resolves the filter once before the
pagination loop, the second pass hit the folders/labels list API again on
every page — 1 + page_count calls total, which easily trips rate limits.

buildListParams now only assembles API params from the already-resolved
FolderID / LabelID produced by resolveListFilter; it no longer resolves
names or aliases. The default folder_id=INBOX is still applied when no
explicit filter is present, and only overridden when the caller supplied a
canonical folder ID. The runtime / mailboxID / dryRun parameters are kept
for signature stability (resolveListFilter and buildSearchParams share the
same call shape).

Adds TestMailTriageCustomFolderResolvesOnceAcrossListPages: a custom-folder
filter forced across two messages-list pages, with a non-reusable folders
list stub so any second folders API call fails the test. Updated the two
existing buildListParams alias tests to run resolveListFilter first, mirroring
the real DryRun/Execute call order.

sprint: S1

Co-authored-by: xukuncx <283114605+xukuncx@users.noreply.github.com>
2026-06-23 18:05:08 +08:00
liangshuo-1
736b131cdf fix(meta): backfill enum value descriptions from options (#1541) 2026-06-23 16:14:42 +08:00
arnold9672
5efaf65aec feat: surface search API notices (#1413)
* feat: surface search API notices

sa: safe
doc: none
cfg: none
test: unit test

* fix: surface search notices in default output

* docs: add search notice doc comments

* docs: expand search notice doc comments
2026-06-23 14:27:04 +08:00
linchao5102
0991da7446 fix: add missing CLI headers for git credential helper (#1539) 2026-06-23 14:25:26 +08:00
zgz2048
80bea45c6a feat: support base record comments (#1043)
* feat: support base record comments

* fix: tighten base comment validation

* fix: validate wiki base comment flags
2026-06-23 11:20:07 +08:00
bubbmon233
c775cb4360 docs(mail): trim lark-mail skill context (#1527) 2026-06-22 21:32:31 +08:00
jiangguozhou
824aa9edf8 docs: add lark-drive permission governance workflow (#1292)
Change-Id: Ib62bd439669fec3e9d5589d1fbe266d3aef964a8
2026-06-22 14:20:02 +08:00
zhanghuanxu
9d4ae94394 feat(slides):slide screenshot 2026-06-22 13:20:39 +08:00
218 changed files with 19567 additions and 2950 deletions

View File

@@ -47,10 +47,13 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -71,11 +74,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -123,7 +126,7 @@ jobs:
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
@@ -255,10 +258,13 @@ jobs:
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
}
let prNumber = Number(runPRs[0]?.number || 0);
let eventBaseSha = runPRs[0]?.base?.sha || "";
const eventBaseSha = runPRs[0]?.base?.sha || "";
const eventHeadSha = runPRs[0]?.head?.sha || "";
const targetHeadSha = eventHeadSha || run.head_sha;
const targetHeadSha = run.head_sha;
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
}
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
@@ -279,11 +285,11 @@ jobs:
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
artifactError = "facts artifact head sha does not match verified PR head sha";
factsArtifactName = "";
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
factsArtifactName = "";
} else {
artifactBaseSha = parsedBaseSha;
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
}
}
}
if (!prNumber) {
@@ -331,7 +337,7 @@ jobs:
core.setOutput("stale", "true");
return;
}
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
core.notice("semantic review skipped: workflow_run is stale for this PR base");

5
.gitignore vendored
View File

@@ -7,6 +7,11 @@ bin/
# Node
node_modules/
# Python (skill-bundled helper scripts)
__pycache__/
*.py[cod]
*$py.class
# OS
.DS_Store

View File

@@ -2,6 +2,30 @@
All notable changes to this project will be documented in this file.
## [v1.0.57] - 2026-06-23
### Features
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
- **base**: Support record comments (#1043)
- **search**: Surface search API notices (#1413)
### Bug Fixes
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
- **meta**: Backfill enum value descriptions from options (#1541)
- **cli**: Add missing CLI headers for git credential helper (#1539)
### Documentation
- **doc**: Refine rich block, path, and block ID guidance (#1508)
- **mail**: Trim lark-mail skill context (#1527)
- **drive**: Add permission governance workflow guidance (#1292)
### Build
- **ci**: Bind semantic review to workflow run head (#1551)
## [v1.0.56] - 2026-06-18
### Features
@@ -1212,6 +1236,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54

View File

@@ -26,6 +26,7 @@ func TestRunList_TextOutput(t *testing.T) {
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
} {
if !strings.Contains(out, want) {
t.Errorf("list output missing %q; full output:\n%s", want, out)
@@ -55,4 +56,17 @@ func TestRunList_JSONOutput(t *testing.T) {
}
}
}
var foundTask bool
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"])
}
}
}
if !foundTask {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
}

View File

@@ -96,6 +96,34 @@ func TestRunSchema_JSONOutput(t *testing.T) {
}
}
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, "task.task.update_user_access_v2", 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["jq_root_path"] != ".event" {
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
}
if payload["single_consumer"] != true {
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
}
resolved := payload["resolved_output_schema"].(map[string]interface{})
props := resolved["properties"].(map[string]interface{})
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
}
}
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
const syntheticKey = "test.evt_sub"
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })

132
events/im/card_action.go Normal file
View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// CardActionTriggerOutput is the flattened shape for card.action.trigger.
type CardActionTriggerOutput struct {
Type string `json:"type" desc:"Event type; always card.action.trigger"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string)" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id" kind:"open_id"`
MessageID string `json:"message_id,omitempty" desc:"Message ID of the card" kind:"message_id"`
ChatID string `json:"chat_id,omitempty" desc:"Chat ID" kind:"chat_id"`
Host string `json:"host,omitempty" desc:"Host type: im_message / im_top_notice"`
Token string `json:"token,omitempty" desc:"Token for delay card update (valid 30 min, max 2 updates)"`
ActionTag string `json:"action_tag,omitempty" desc:"Triggered element type: button/select_static/input/checker/etc"`
ActionValue string `json:"action_value,omitempty" desc:"Developer-defined action value as JSON string"`
ActionName string `json:"action_name,omitempty" desc:"Element name attribute"`
FormValue string `json:"form_value,omitempty" desc:"Form submission values as JSON string (only on form submit)"`
InputValue string `json:"input_value,omitempty" desc:"Input field value (only for input elements)"`
Option string `json:"option,omitempty" desc:"Selected option value (for single-select dropdown)"`
Options string `json:"options,omitempty" desc:"Selected options, comma-separated (for multi-select)"`
Checked bool `json:"checked" desc:"Checkbox state (for checkbox elements)"`
Timezone string `json:"timezone,omitempty" desc:"User timezone for date/time picker interactions"`
CardContent string `json:"card_content,omitempty" desc:"Original card JSON content (body.content) auto-fetched via message get API at consume time using message_id; empty if message_id absent or fetch fails"`
}
func processCardAction(ctx context.Context, rt 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 {
Operator struct {
OpenID string `json:"open_id"`
} `json:"operator"`
Token string `json:"token"`
Host string `json:"host"`
Action struct {
Tag string `json:"tag"`
Value map[string]interface{} `json:"value"`
Name string `json:"name"`
FormValue map[string]interface{} `json:"form_value"`
InputValue string `json:"input_value"`
Option string `json:"option"`
Options []string `json:"options"`
Checked bool `json:"checked"`
Timezone string `json:"timezone"`
} `json:"action"`
Context struct {
OpenMessageID string `json:"open_message_id"`
OpenChatID string `json:"open_chat_id"`
} `json:"context"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload
}
actionValue := marshalToString(envelope.Event.Action.Value)
formValue := marshalToString(envelope.Event.Action.FormValue)
options := strings.Join(envelope.Event.Action.Options, ",")
out := &CardActionTriggerOutput{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
OperatorID: envelope.Event.Operator.OpenID,
MessageID: envelope.Event.Context.OpenMessageID,
ChatID: envelope.Event.Context.OpenChatID,
Host: envelope.Event.Host,
Token: envelope.Event.Token,
ActionTag: envelope.Event.Action.Tag,
ActionValue: actionValue,
ActionName: envelope.Event.Action.Name,
FormValue: formValue,
InputValue: envelope.Event.Action.InputValue,
Option: envelope.Event.Action.Option,
Options: options,
Checked: envelope.Event.Action.Checked,
Timezone: envelope.Event.Action.Timezone,
}
if out.MessageID != "" && rt != nil {
out.CardContent = fetchCardUserDSL(ctx, rt, out.MessageID)
}
return json.Marshal(out)
}
// fetchCardUserDSL gets the card message content via message get API.
// Returns empty string on any failure — never blocks event consumption.
func fetchCardUserDSL(ctx context.Context, rt event.APIClient, messageID string) string {
path := "/open-apis/im/v1/messages/" + messageID + "?card_msg_content_type=user_card_content"
resp, err := rt.CallAPI(ctx, "GET", path, nil)
if err != nil {
return ""
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Items []struct {
Body struct {
Content string `json:"content"`
} `json:"body"`
} `json:"items"`
} `json:"data"`
}
if json.Unmarshal(resp, &result) != nil || result.Code != 0 || len(result.Data.Items) == 0 {
return ""
}
return result.Data.Items[0].Body.Content
}
func marshalToString(m map[string]interface{}) string {
if len(m) == 0 {
return ""
}
b, _ := json.Marshal(m)
return string(b)
}

View File

@@ -0,0 +1,432 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestCardActionTriggerRegistered(t *testing.T) {
def, ok := event.Lookup("card.action.trigger")
if !ok {
t.Fatal("card.action.trigger should be registered via Keys()")
}
if def.Schema.Custom == nil {
t.Error("card.action.trigger must set Schema.Custom")
}
if def.Process == nil {
t.Error("card.action.trigger must set Process")
}
if len(def.Scopes) == 0 {
t.Error("Scopes must not be empty")
}
}
func TestProcessCardAction_Button(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_btn_001",
"event_type": "card.action.trigger",
"create_time": "1776409469273"
},
"event": {
"operator": {"open_id": "ou_operator"},
"token": "c-token-btn",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "approve"},
"name": "approve_btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_msg_001",
"open_chat_id": "oc_chat_001"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Type != "card.action.trigger" {
t.Errorf("Type = %q, want card.action.trigger", out.Type)
}
if out.EventID != "ev_btn_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.OperatorID != "ou_operator" {
t.Errorf("OperatorID = %q", out.OperatorID)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
if out.ActionValue != `{"key":"approve"}` {
t.Errorf("ActionValue = %q", out.ActionValue)
}
if out.ActionName != "approve_btn" {
t.Errorf("ActionName = %q", out.ActionName)
}
if out.Token != "c-token-btn" {
t.Errorf("Token = %q", out.Token)
}
if out.MessageID != "om_msg_001" {
t.Errorf("MessageID = %q", out.MessageID)
}
if out.ChatID != "oc_chat_001" {
t.Errorf("ChatID = %q", out.ChatID)
}
if out.Host != "im_message" {
t.Errorf("Host = %q", out.Host)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
}
func TestProcessCardAction_FormSubmit(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_form_001",
"event_type": "card.action.trigger",
"create_time": "1776409469274"
},
"event": {
"operator": {"open_id": "ou_form_user"},
"token": "c-token-form",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "submit_btn",
"form_value": {"name": "test-user", "reason": "testing"},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_form_001",
"open_chat_id": "oc_chat_002"
}
}
}`
out := runCardAction(t, payload, nil)
if out.FormValue != `{"name":"test-user","reason":"testing"}` {
t.Errorf("FormValue = %q", out.FormValue)
}
if out.ActionTag != "button" {
t.Errorf("ActionTag = %q, want button", out.ActionTag)
}
}
func TestProcessCardAction_MultiSelect(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_ms_001",
"event_type": "card.action.trigger",
"create_time": "1776409469275"
},
"event": {
"operator": {"open_id": "ou_ms_user"},
"token": "c-token-ms",
"host": "im_message",
"action": {
"tag": "multi_select_static",
"value": {},
"name": "multi_select",
"options": ["opt_1", "opt_3"],
"checked": false
},
"context": {
"open_message_id": "om_ms_001",
"open_chat_id": "oc_chat_003"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Options != "opt_1,opt_3" {
t.Errorf("Options = %q, want opt_1,opt_3", out.Options)
}
if out.ActionTag != "multi_select_static" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_Input(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_input_001",
"event_type": "card.action.trigger",
"create_time": "1776409469276"
},
"event": {
"operator": {"open_id": "ou_input_user"},
"token": "c-token-input",
"host": "im_message",
"action": {
"tag": "input",
"value": {},
"name": "text_input",
"input_value": "hello world",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_input_001",
"open_chat_id": "oc_chat_004"
}
}
}`
out := runCardAction(t, payload, nil)
if out.InputValue != "hello world" {
t.Errorf("InputValue = %q", out.InputValue)
}
if out.ActionTag != "input" {
t.Errorf("ActionTag = %q", out.ActionTag)
}
}
func TestProcessCardAction_DatePicker(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_date_001",
"event_type": "card.action.trigger",
"create_time": "1776409469277"
},
"event": {
"operator": {"open_id": "ou_date_user"},
"token": "c-token-date",
"host": "im_message",
"action": {
"tag": "date_picker",
"value": {},
"name": "date_selector",
"option": "2024-04-01 +0800",
"timezone": "Asia/Shanghai",
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_date_001",
"open_chat_id": "oc_chat_005"
}
}
}`
out := runCardAction(t, payload, nil)
if out.Option != "2024-04-01 +0800" {
t.Errorf("Option = %q", out.Option)
}
if out.Timezone != "Asia/Shanghai" {
t.Errorf("Timezone = %q", out.Timezone)
}
}
func TestProcessCardAction_MalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: "card.action.trigger",
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processCardAction(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 TestProcessCardAction_MessageGetSuccess(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ok",
"event_type": "card.action.trigger",
"create_time": "1776409469278"
},
"event": {
"operator": {"open_id": "ou_mg_user"},
"token": "c-token-mg",
"host": "im_message",
"action": {
"tag": "button",
"value": {"key": "click"},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_001",
"open_chat_id": "oc_chat_mg"
}
}
}`
cardContent := `{"header":{"title":{"tag":"plain_text","content":"A card"}}}`
mock := &mockAPIClient{resp: `{
"code": 0,
"msg": "success",
"data": {
"items": [{
"body": {"content": "` + escapeJSON(cardContent) + `"}
}]
}
}`}
out := runCardAction(t, payload, mock)
if out.CardContent == "" {
t.Error("CardContent should not be empty when message get succeeds")
}
}
func TestProcessCardAction_MessageGetErrorCode(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_ec",
"event_type": "card.action.trigger",
"create_time": "1776409469279"
},
"event": {
"operator": {"open_id": "ou_mg_user2"},
"token": "c-token-mg2",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_002",
"open_chat_id": "oc_chat_mg2"
}
}
}`
mock := &mockAPIClient{resp: `{"code": 1, "msg": "error", "data": {"items": []}}`}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when code != 0, got %q", out.CardContent)
}
}
func TestProcessCardAction_MessageGetFailure(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_mg_fail",
"event_type": "card.action.trigger",
"create_time": "1776409469280"
},
"event": {
"operator": {"open_id": "ou_mg_user3"},
"token": "c-token-mg3",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "om_mg_003",
"open_chat_id": "oc_chat_mg3"
}
}
}`
mock := &mockAPIClient{errResp: true}
out := runCardAction(t, payload, mock)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message get fails, got %q", out.CardContent)
}
}
func TestProcessCardAction_EmptyMessageID(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_no_msg",
"event_type": "card.action.trigger",
"create_time": "1776409469281"
},
"event": {
"operator": {"open_id": "ou_no_msg"},
"token": "c-token-nm",
"host": "im_message",
"action": {
"tag": "button",
"value": {},
"name": "btn",
"form_value": {},
"options": [],
"checked": false
},
"context": {
"open_message_id": "",
"open_chat_id": "oc_chat_nm"
}
}
}`
out := runCardAction(t, payload, nil)
if out.CardContent != "" {
t.Errorf("CardContent should be empty when message_id is absent, got %q", out.CardContent)
}
}
type mockAPIClient struct {
resp string
errResp bool
}
func (m *mockAPIClient) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) {
if m.errResp {
return nil, context.DeadlineExceeded
}
return json.RawMessage(m.resp), nil
}
func runCardAction(t *testing.T, payload string, rt event.APIClient) CardActionTriggerOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: "card.action.trigger",
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processCardAction(context.Background(), rt, raw, nil)
if err != nil {
t.Fatalf("Process error: %v", err)
}
var out CardActionTriggerOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid CardActionTriggerOutput JSON: %v\nraw=%s", err, string(got))
}
return out
}
func escapeJSON(s string) string {
b, _ := json.Marshal(s)
return string(b[1 : len(b)-1])
}

View File

@@ -27,6 +27,21 @@ func Keys() []event.KeyDefinition {
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{"im.message.receive_v1"},
},
{
Key: "card.action.trigger",
DisplayName: "Card action",
Description: "Triggered when a user interacts with an interactive card (button click, form submit, dropdown select, etc.). Output includes: token (valid 30 min, max 2 updates), action details (tag, value, name, form_value), and card_content (original card in userDSL text format, auto-fetched at consume time). To update the card: parse card_content to understand the current state, construct the new card JSON, then call `lark-cli api POST /open-apis/interactive/v1/card/update` with the token (see lark-im-card-action-reply.md).",
EventType: "card.action.trigger",
SubscriptionType: event.SubTypeCallback,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(CardActionTriggerOutput{})},
},
Process: processCardAction,
Scopes: []string{"im:message:readonly"},
AuthTypes: []string{"bot"},
SingleConsumer: true,
RequiredConsoleEvents: []string{"card.action.trigger"},
},
}
for _, rk := range nativeIMKeys {

View File

@@ -7,6 +7,7 @@ package events
import (
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
@@ -17,6 +18,7 @@ func init() {
all := [][]event.KeyDefinition{
im.Keys(),
minutes.Keys(),
task.Keys(),
vc.Keys(),
whiteboard.Keys(),
}

23
events/task/native.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
// standard Lark V2 event envelope.
type TaskUpdateUserAccessV2Data struct {
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
}
var taskUpdateUserAccessCommitTypes = []string{
"task_create",
"task_deleted",
"task_summary_update",
"task_desc_update",
"task_assignees_update",
"task_followers_update",
"task_reminders_update",
"task_start_due_update",
"task_completed_update",
}

32
events/task/preconsume.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"failed to subscribe task event",
).WithCause(err)
}
return nil, nil
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
type stubAPIClient struct {
err error
method string
path string
body interface{}
calls int
}
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
s.method = method
s.path = path
s.body = body
s.calls++
if s.err != nil {
return nil, s.err
}
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
}
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
rt := &stubAPIClient{}
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != nil {
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
}
if cleanup != nil {
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
}
if rt.calls != 1 {
t.Fatalf("calls = %d, want 1", rt.calls)
}
if rt.method != "POST" {
t.Errorf("method = %q, want POST", rt.method)
}
if rt.path != taskSubscriptionPath {
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
}
if rt.body != nil {
t.Errorf("body = %#v, want nil", rt.body)
}
}
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
if err == nil {
t.Fatal("expected error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryInternal {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
}
}
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
rt := &stubAPIClient{err: wantErr}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err != wantErr {
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryValidation {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
}
}
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
cause := errors.New("connection reset")
rt := &stubAPIClient{err: cause}
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, cause) {
t.Fatalf("err = %v, want cause %v", err, cause)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if p.Category != errs.CategoryNetwork {
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
}
if p.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
}
}

33
events/task/register.go Normal file
View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package task registers Task-domain EventKeys.
package task
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
// Keys returns all Task-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeTaskUpdateUserAccessV2,
DisplayName: "Task updated",
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
EventType: eventTypeTaskUpdateUserAccessV2,
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
},
PreConsume: taskSubscriptionPreConsume,
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
SingleConsumer: true,
},
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"reflect"
"testing"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
}
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
}
if def.Schema.Native == nil {
t.Fatal("Schema.Native is nil")
}
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
}
if def.Process != nil {
t.Fatal("Native Task EventKey must not set Process")
}
if def.PreConsume == nil {
t.Fatal("PreConsume is nil")
}
if !def.SingleConsumer {
t.Fatal("SingleConsumer = false, want true")
}
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
t.Errorf("Scopes = %#v", def.Scopes)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
taskGUID := eventProps["task_guid"].(map[string]interface{})
if got := taskGUID["format"]; got != "task_guid" {
t.Errorf("task_guid format = %v, want task_guid", got)
}
eventTypes := eventProps["event_types"].(map[string]interface{})
items := eventTypes["items"].(map[string]interface{})
rawEnum, ok := items["enum"].([]interface{})
if !ok {
t.Fatalf("event_types item enum missing: %#v", items["enum"])
}
got := make(map[string]bool, len(rawEnum))
for _, v := range rawEnum {
got[v.(string)] = true
}
for _, want := range taskUpdateUserAccessCommitTypes {
if !got[want] {
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
}
}
}
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
const key = eventTypeTaskUpdateUserAccessV2
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}

View File

@@ -131,31 +131,3 @@ func requireInTrustedDirs(effectivePath string, trustedDirs []string, label stri
}
return fmt.Errorf("%s: path %q is not inside any trusted directory", label, effectivePath)
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -29,3 +29,31 @@ func checkOwnerUID(path, label string) error {
}
return nil
}
// auditFilePermissions rejects world/group-writable modes (always) and
// world/group-readable modes (unless allowReadableByOthers is true, which
// exec commands typically need for their usual 755 mode).
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
info, err := vfs.Stat(effectivePath)
if err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
mode := info.Mode().Perm()
if mode&0o002 != 0 {
return fmt.Errorf("%s: path %q is world-writable (mode %04o)", label, effectivePath, mode)
}
if mode&0o020 != 0 {
return fmt.Errorf("%s: path %q is group-writable (mode %04o)", label, effectivePath, mode)
}
if allowReadableByOthers {
return nil
}
if mode&0o004 != 0 {
return fmt.Errorf("%s: path %q is world-readable (mode %04o)", label, effectivePath, mode)
}
if mode&0o040 != 0 {
return fmt.Errorf("%s: path %q is group-readable (mode %04o)", label, effectivePath, mode)
}
return nil
}

View File

@@ -5,7 +5,22 @@
package binding
import (
"fmt"
"github.com/larksuite/cli/internal/vfs"
)
// checkOwnerUID is a no-op on Windows where Unix UID semantics don't apply.
func checkOwnerUID(path, label string) error {
return nil
}
// auditFilePermissions skips POSIX permission-bit auditing on Windows because
// Go synthesizes mode bits from file attributes rather than NTFS ACLs.
func auditFilePermissions(effectivePath string, allowReadableByOthers bool, label string) error {
if _, err := vfs.Stat(effectivePath); err != nil {
return fmt.Errorf("%s: cannot stat %q: %w", label, effectivePath, err)
}
return nil
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build windows
package binding
import (
"os"
"path/filepath"
"testing"
)
func TestAssertSecurePath_WindowsIgnoresSyntheticUnixPermissionBits(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "secrets-getter.cmd")
if err := os.WriteFile(p, []byte("@echo off\r\n"), 0o600); err != nil {
t.Fatalf("write temp command: %v", err)
}
got, err := AssertSecurePath(AuditParams{
TargetPath: p,
Label: "exec provider command",
AllowInsecurePath: false,
AllowReadableByOthers: true,
})
if err != nil {
t.Fatalf("unexpected error for Windows synthetic mode bits: %v", err)
}
if got != p {
t.Errorf("got %q, want %q", got, p)
}
}

View File

@@ -113,7 +113,8 @@ type EnumOption struct {
}
// EnumOptions returns the field's allowed values paired with their descriptions
// — from enum, or from options when enum is absent — coerced to the canonical
// — from enum (with descriptions backfilled from options when the field carries
// both forms), or from options when enum is absent — coerced to the canonical
// type and ordered: numeric and boolean values are sorted; string values keep
// source order (which can encode priority). Uncoercible literals are dropped.
// Returns nil when the field declares no enum constraint.
@@ -122,9 +123,14 @@ func (f Field) EnumOptions() []EnumOption {
var out []EnumOption
switch {
case len(f.Enum) > 0:
// key by raw literal so enum "1" and option 1 align across JSON types
desc := make(map[string]string, len(f.Options))
for _, o := range f.Options {
desc[fmt.Sprintf("%v", o.Value)] = o.Description
}
for _, e := range f.Enum {
if v, ok := coerceLiteral(ct, e); ok {
out = append(out, EnumOption{Value: v})
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
}
}
case len(f.Options) > 0:

View File

@@ -80,6 +80,39 @@ func TestField_EnumOptions(t *testing.T) {
}
}
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
// enum is the value set; descriptions backfilled from options, empty where absent
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "6", Description: "subject"},
}}
want := []EnumOption{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
{Value: "3", Description: ""},
{Value: "4", Description: ""},
{Value: "6", Description: "subject"},
}
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
}
// enum values stored as strings match option values stored as numbers
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
{Value: 1, Description: "one"},
{Value: 2, Description: "two"},
}}
wantI := []EnumOption{
{Value: int64(1), Description: "one"},
{Value: int64(2), Description: "two"},
{Value: int64(10), Description: ""},
}
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
}
}
func TestField_Enum_NumberAndBoolean(t *testing.T) {
// number: string-stored floats coerced to float64 and numerically sorted
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {

View File

@@ -472,6 +472,18 @@ func TestConvert_EnumDescriptions(t *testing.T) {
if bare.EnumDescriptions != nil {
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
}
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
{Value: "1", Description: "from"},
{Value: "2", Description: "to"},
}})
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
t.Errorf("both Enum = %v", both.Enum)
}
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
}
}
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {

View File

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

View File

@@ -179,7 +179,10 @@ fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
@@ -198,8 +201,9 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
@@ -210,8 +214,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/larksuite/cli/extension/fileio"
@@ -84,6 +85,9 @@ var AppsHTMLPublish = common.Shortcut{
// for dry-run "advisory preview" semantics).
dry.Set("validation_error", err.Error())
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
dry.Set("oversize_html", hits)
}
dry.Set("file_count", len(candidates))
var totalSize int64
names := make([]string, 0, len(candidates))
@@ -140,18 +144,22 @@ type appsHTMLPublishSpec struct {
// per-environment .env.* files for every stage).
const maxSensitiveListInError = 5
// truncatedJoin joins items with ", ", capping at max entries and appending
// "(and N more)" for the remainder, so an inline error list stays readable when
// a payload has many hits.
func truncatedJoin(items []string, max int) string {
if len(items) <= max {
return strings.Join(items, ", ")
}
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
}
// sensitiveCandidatesError builds the Validate-time rejection when --path
// contains credential files and --allow-sensitive was not set.
func sensitiveCandidatesError(hits []string) error {
var sample string
if len(hits) <= maxSensitiveListInError {
sample = strings.Join(hits, ", ")
} else {
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
}
return appsValidationParamError("--path",
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
"--path contains %d credential file(s) that should not be published: %s",
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
}
@@ -168,6 +176,30 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
// Mutable for tests.
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
// 用 var 而非 const便于单测调小覆盖拦截路径。
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html大小写不敏感且单个 Size 超过
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
var hits []string
for _, c := range candidates {
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
hits = append(hits, c.RelPath)
}
}
return hits
}
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
func oversizeHTMLFilesError(hits []string) error {
return appsValidationParamError("--path",
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
}
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
// 目录形态:根目录下必须有 index.html。
// 单文件形态:文件名必须就是 index.html。
@@ -190,6 +222,9 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
if err := ensureIndexHTML(candidates); err != nil {
return nil, err
}
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
return nil, oversizeHTMLFilesError(hits)
}
var rawTotal int64
for _, c := range candidates {
rawTotal += c.Size

View File

@@ -503,3 +503,82 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
cands := []htmlPublishCandidate{
{RelPath: "index.html", Size: 50},
{RelPath: "big.html", Size: 4096},
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
{RelPath: "huge.png", Size: 9000}, // 非 .html忽略
}
hits := oversizeHTMLFiles(cands)
if len(hits) != 2 {
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
}
for _, h := range hits {
if h == "huge.png" || h == "index.html" {
t.Fatalf("unexpected hit %q", h)
}
}
}
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
}
}
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected per-file oversize error")
}
problem := requireAppsValidationProblem(t, err)
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -99,7 +99,14 @@ var AppsInit = common.Shortcut{
dry.Set("dir_error", err.Error())
dir = defaultCloneDir(appID)
} else if isAlreadyInitialized(dir) {
dry.Set("already_initialized", true)
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
if existing != "" {
dry.Set("app_id_mismatch", existing)
}
dry.Set("dir_error", e.Error())
} else {
dry.Set("already_initialized", true)
}
} else if e := ensureEmptyDir(dir); e != nil {
dry.Set("dir_error", e.Error())
}
@@ -199,6 +206,61 @@ func isAlreadyInitialized(dir string) bool {
return err == nil && !info.IsDir()
}
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id用于判断目标目录是否同一个妙搭应用。
// 返回 (appID, isSparkProject, err)
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
// - 解析成功 → (trim 后的 app_id, true, nil)app_id 缺失/为空时为 ""
func readMetaAppID(dir string) (string, bool, error) {
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
if os.IsNotExist(err) {
return "", false, nil
}
if err != nil {
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
}
var m struct {
AppID string `json:"app_id"`
}
if err := json.Unmarshal(b, &m); err != nil {
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
}
return strings.TrimSpace(m.AppID), true, nil
}
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
// - 不是妙搭工程(无 meta.json → nil交给 ensureEmptyDir 判空/非空)
// - 是妙搭工程且 app_id 与 appID 一致 → nil走已初始化短路复用本地代码
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
// - meta.json 损坏/不可读,无法确认 → 报错fail closed提示换目录
//
// 返回值 existing 是目录里已存在的 app_id仅"已是另一个 app"的拒绝场景非空),供调用方在
// dry-run 里回填 app_id_mismatch避免二次读 meta.json。
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
existing, isSpark, readErr := readMetaAppID(dir)
if readErr != nil {
return "", appsValidationParamError("--dir",
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
WithCause(readErr)
}
if !isSpark || existing == appID {
return existing, nil
}
if existing == "" {
// meta 存在但缺 app_id更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
return "", appsValidationParamError("--dir",
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
dir, metaRelPath, appID).
WithHint("remove the directory and re-run +init, or choose a different --dir")
}
return existing, appsValidationParamError("--dir",
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
dir, existing, appID).
WithHint("choose a different --dir (or cd into the matching project) before running +init")
}
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
// the file does not exist, this is a no-op (we never create it).
@@ -378,6 +440,11 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
return err
}
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
// initialized app repo -> skip clone/scaffold/commit, but still refresh
// the local env so a re-run picks up the latest startup env vars.

View File

@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
@@ -394,6 +394,40 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
}
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
dir := relCloneDir(t)
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
if err == nil {
t.Fatal("mismatched app_id must error")
}
problem := requireAppsValidationProblem(t, err)
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--dir" {
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
}
if !strings.Contains(problem.Message, "different app") {
t.Fatalf("message=%q, want 'different app'", problem.Message)
}
for _, c := range f.calls {
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
}
}
}
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_x.git"),
@@ -1468,6 +1502,125 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
}
}
func TestReadMetaAppID(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 不存在 meta.json → ("", false, nil)
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
}
// 存在且有 app_id → (app_id, true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
}
// 存在但 app_id 空 → ("", true, nil)
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
}
// 存在但坏 JSON → ("", false, err)(无法确认)
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
}
}
func TestEnsureInitDirMatchesApp(t *testing.T) {
writeMeta := func(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return dir
}
// 无 meta非妙搭工程→ nil交给 ensureEmptyDir
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
t.Fatalf("no meta should pass: %v", err)
}
// 同 app_id → (app_id, nil)(走已初始化短路)
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
}
// 不同 app_id → error换目录返回 existing=app_other断言 typed metadatasubtype/param
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
if errMismatch == nil {
t.Fatal("different app should error")
}
if existing != "app_other" {
t.Fatalf("mismatch should return existing app_id, got %q", existing)
}
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(errMismatch, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
}
if ve.Param != "--dir" {
t.Fatalf("param=%q, want --dir", ve.Param)
}
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
}
if !strings.Contains(problem.Hint, "different --dir") {
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
}
// 空 app_id缺 app_id 标记的半成品)→ error独立文案非 "different app"),返回 existing=""
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
if errEmpty == nil {
t.Fatal("empty meta app_id should error (cannot confirm same app)")
}
if emptyExisting != "" {
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
}
pEmpty := requireAppsValidationProblem(t, errEmpty)
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pEmpty.Message, "without an app_id") {
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
}
if strings.Contains(pEmpty.Message, "different app") {
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
}
// meta 损坏/不可读 → errorfail closed返回 existing=""
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
if errBad == nil {
t.Fatal("corrupted meta should fail closed")
}
if badExisting != "" {
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
}
pBad := requireAppsValidationProblem(t, errBad)
if pBad.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
}
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
}
var veBad *errs.ValidationError
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
}
}
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
// classification of an external-tool failure: a failing git subprocess
// surfaces as internal/external_tool with the cause preserved.

View File

@@ -17,6 +17,7 @@ import (
"text/tabwriter"
"time"
"github.com/google/uuid"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
@@ -32,6 +33,7 @@ import (
)
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
// gitCredentialIssueHint is the actionable next-step attached to a failed
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
@@ -302,7 +304,12 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
HttpMethod: http.MethodGet,
ApiPath: issuePath(appID),
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
ctx = contextWithGitCredentialHelperShortcut(ctx)
var opts []larkcore.RequestOptionFunc
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
opts = append(opts, optFn)
}
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
Brand: string(cfg.Brand),
AppID: cfg.AppID,
@@ -314,6 +321,13 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
return issuedFromData(appID, data)
}
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
return ctx
}
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
}
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
if f == nil || f.IOStreams == nil {
return nil

View File

@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
func TestFactoryIssuerBranches(t *testing.T) {
factory, _, reg := newAppsExecuteFactory(t)
expiresAt := time.Now().Add(24 * time.Hour).Unix()
reg.Register(&httpmock.Stub{
issueStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
Body: map[string]interface{}{
@@ -836,7 +836,8 @@ func TestFactoryIssuerBranches(t *testing.T) {
"StatusCode": 0,
},
},
})
}
reg.Register(issueStub)
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
if err != nil {
t.Fatalf("factory issuer returned error: %v", err)
@@ -844,6 +845,12 @@ func TestFactoryIssuerBranches(t *testing.T) {
if issued.PAT != "pat-token" {
t.Fatalf("PAT = %q", issued.PAT)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
}
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
}
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
@@ -880,6 +887,20 @@ func TestFactoryIssuerBranches(t *testing.T) {
}
}
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
got := contextWithGitCredentialHelperShortcut(ctx)
name, ok := cmdutil.ShortcutNameFromContext(got)
if !ok || name != "apps:+git-credential-init" {
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
}
executionID, ok := cmdutil.ExecutionIdFromContext(got)
if !ok || executionID != "exec-existing" {
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
}
}
func TestGitCredentialHelpersAndParsers(t *testing.T) {
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))

View File

@@ -0,0 +1,545 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"fmt"
"net/url"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
baseURLResolveHintGeneric = "Provide a /base/, /wiki/, or /record/ URL, or use base +title-resolve --title if you only know the Base title."
baseTitleResolveHint = "choose one candidate, then use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
nextStepBaseBlockList = "use +base-block-list to list tables, dashboards, workflows, and other Base blocks"
nextStepRecordList = "use +record-list to list records in the resolved table"
titleResolveQueryMaxLen = 30
)
var BaseURLResolve = common.Shortcut{
Service: "base",
Command: "+url-resolve",
Description: "Resolve a Base-related URL into Base coordinates",
Risk: "read",
Scopes: []string{},
ConditionalScopes: []string{
"base:field:read",
"base:record:read",
"wiki:node:retrieve",
},
AuthTypes: authTypes(),
HasFormat: true,
Flags: []common.Flag{
{Name: "url", Desc: "Base/Wiki/record-share URL to resolve"},
{Name: "query", Hidden: true, Desc: "Alias for --url; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +url-resolve --url "https://example.larkoffice.com/base/<base_token>?table=<table_id>&view=<view_id>"`,
"Only URLs are accepted. For Base titles or keywords, use +title-resolve --title.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readURLResolveInput(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
raw, err := readURLResolveInput(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
parsed, err := parseResolveURL(raw)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
switch classifyBaseURL(parsed) {
case "wiki_url":
return common.NewDryRunAPI().
GET("/open-apis/wiki/v2/spaces/get_node").
Params(map[string]interface{}{"token": firstPathSegmentAfter(parsed.Path, "/wiki/")})
case "record_share_url":
return common.NewDryRunAPI().
GET("/open-apis/base/v3/record_share/:record_share_token/meta").
Set("record_share_token", firstPathSegmentAfter(parsed.Path, "/record/"))
default:
return common.NewDryRunAPI().Set("url", raw).Set("resolution", "local")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseURLResolve(runtime)
},
}
var BaseTitleResolve = common.Shortcut{
Service: "base",
Command: "+title-resolve",
Description: "Resolve a Base title or keyword through Drive search",
Risk: "read",
Scopes: []string{"search:docs:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "title", Desc: "Base title keyword to search via Drive (30 characters or fewer)"},
{Name: "query", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
{Name: "url", Hidden: true, Desc: "Alias for --title; accepted to recover from AI routing mistakes"},
},
Tips: []string{
`Example: lark-cli base +title-resolve --title "Sales pipeline"`,
"Pass a short keyword from the Base title, 30 characters or fewer. Use +url-resolve for URLs.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readTitleResolveQuery(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
query, err := readTitleResolveQuery(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/search/v2/doc_wiki/search").
Body(buildTitleResolveSearchBody(query))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseTitleResolve(runtime)
},
}
func readURLResolveInput(runtime *common.RuntimeContext) (string, error) {
urlValue := strings.TrimSpace(runtime.Str("url"))
queryValue := strings.TrimSpace(runtime.Str("query"))
if urlValue != "" && queryValue != "" {
return "", baseFlagErrorf("--url and --query are mutually exclusive")
}
value := urlValue
if value == "" {
value = queryValue
}
if value == "" {
return "", baseFlagErrorf("specify --url")
}
return value, nil
}
func readTitleResolveQuery(runtime *common.RuntimeContext) (string, error) {
values := []struct {
name string
value string
}{
{"title", strings.TrimSpace(runtime.Str("title"))},
{"query", strings.TrimSpace(runtime.Str("query"))},
{"url", strings.TrimSpace(runtime.Str("url"))},
}
var pickedName, pickedValue string
for _, v := range values {
if v.value == "" {
continue
}
if pickedValue != "" {
return "", baseFlagErrorf("--%s and --%s are mutually exclusive", pickedName, v.name)
}
pickedName = v.name
pickedValue = v.value
}
if pickedValue == "" {
return "", baseFlagErrorf("specify --title")
}
if len([]rune(pickedValue)) > titleResolveQueryMaxLen {
return "", resolveValidationError(
fmt.Sprintf("base +title-resolve title keyword must be %d characters or fewer.", titleResolveQueryMaxLen),
"Use a shorter keyword from the Base title, or provide a /base/ URL and use base +url-resolve.",
)
}
return pickedValue, nil
}
func executeBaseURLResolve(runtime *common.RuntimeContext) error {
raw, err := readURLResolveInput(runtime)
if err != nil {
return err
}
parsed, err := parseResolveURL(raw)
if err != nil {
return err
}
switch classifyBaseURL(parsed) {
case "base_url":
out := resolveBaseURL(parsed)
enrichBaseResolveHint(runtime, out)
runtime.OutFormat(out, nil, nil)
return nil
case "wiki_url":
out, err := resolveWikiBaseURL(runtime, parsed)
if err != nil {
return err
}
runtime.OutFormat(out, nil, nil)
return nil
case "record_share_url":
out, err := resolveRecordShareURL(runtime, parsed)
if err != nil {
return err
}
runtime.OutFormat(out, nil, nil)
return nil
case "form_share_url":
runtime.OutFormat(resolveFormShareURL(parsed), nil, nil)
return nil
case "view_share_url":
return resolveValidationError(
"This is a Base view share URL. CLI does not support resolving Base view share URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
case "dashboard_share_url":
return resolveValidationError(
"This is a Base dashboard share URL. CLI does not support resolving Base dashboard share URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
case "workspace_url":
return resolveValidationError(
"This is a Base workspace URL. CLI does not support resolving Base workspace URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
case "add_record_url":
return resolveValidationError(
"This is a Base add-record URL. CLI does not support resolving Base add-record URLs.",
"Open it in the browser, or provide the URL of the Base itself, such as its Wiki URL or Base URL.",
)
default:
return resolveValidationError("This URL is not a supported Base URL pattern.", baseURLResolveHintGeneric)
}
}
func parseResolveURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, resolveValidationError("base +url-resolve only accepts full URLs.", "For a Base title or keyword, use base +title-resolve --title.")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, resolveValidationError("base +url-resolve only accepts HTTP or HTTPS URLs.", baseURLResolveHintGeneric)
}
return parsed, nil
}
func classifyBaseURL(u *url.URL) string {
path := normalizeResolvePath(u.Path)
switch {
case pathSegmentExists(path, "/base/workspace/"):
return "workspace_url"
case pathSegmentExists(path, "/base/add/"):
return "add_record_url"
case pathSegmentExists(path, "/base/"):
return "base_url"
case pathSegmentExists(path, "/wiki/"):
return "wiki_url"
case pathSegmentExists(path, "/record/"):
return "record_share_url"
case pathSegmentExists(path, "/share/base/form/"):
return "form_share_url"
case pathSegmentExists(path, "/share/base/view/"):
return "view_share_url"
case pathSegmentExists(path, "/share/base/dashboard/"):
return "dashboard_share_url"
default:
return ""
}
}
func resolveBaseURL(u *url.URL) map[string]interface{} {
query := u.Query()
out := map[string]interface{}{
"input_type": "base_url",
"resource_type": "bitable",
"base_token": firstPathSegmentAfter(u.Path, "/base/"),
}
if tableID := strings.TrimSpace(query.Get("table")); tableID != "" {
out["table_id"] = tableID
}
if viewID := strings.TrimSpace(query.Get("view")); viewID != "" {
out["view_id"] = viewID
}
if recordID := strings.TrimSpace(query.Get("record")); recordID != "" {
out["record_id"] = recordID
}
return out
}
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
if err != nil {
return nil, err
}
node := common.GetMap(data, "node")
objType := strings.TrimSpace(common.GetString(node, "obj_type"))
if objType != "bitable" {
return nil, resolveValidationError(
fmt.Sprintf("This Wiki URL resolves to %s, not Base.", valueOrUnknown(objType)),
"Use the corresponding skill for that resource, or provide a Base URL.",
)
}
baseToken := strings.TrimSpace(common.GetString(node, "obj_token"))
if baseToken == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki node response is missing obj_token")
}
return map[string]interface{}{
"input_type": "wiki_url",
"resource_type": "bitable",
"wiki_node_token": token,
"base_token": baseToken,
"title": common.GetString(node, "title"),
"hint": resolveHint("", nil),
}, nil
}
func resolveRecordShareURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
shareToken := firstPathSegmentAfter(u.Path, "/record/")
data, err := baseV3Call(runtime, "GET", baseV3Path("record_share", shareToken, "meta"), nil, nil)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"input_type": "record_share_url",
"resource_type": "bitable",
"record_share_token": firstNonEmpty(common.GetString(data, "record_share_token"), shareToken),
"base_token": common.GetString(data, "base_token"),
"table_id": common.GetString(data, "table_id"),
"record_id": common.GetString(data, "record_id"),
}
enrichRecordShareResolveHint(runtime, out)
return out, nil
}
func resolveFormShareURL(u *url.URL) map[string]interface{} {
return map[string]interface{}{
"input_type": "form_share_url",
"resource_type": "bitable_form",
"share_token": firstPathSegmentAfter(u.Path, "/share/base/form/"),
"hint": map[string]interface{}{
"next_step": "use +form-detail to inspect the form, or use +form-submit to submit a response",
},
}
}
func executeBaseTitleResolve(runtime *common.RuntimeContext) error {
query, err := readTitleResolveQuery(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, buildTitleResolveSearchBody(query))
if err != nil {
return err
}
candidates := normalizeTitleResolveCandidates(common.GetSlice(data, "res_units"))
switch len(candidates) {
case 0:
return resolveValidationError(
"No Base matched this title or keyword.",
"Try a more specific Base title, or provide a /base/ URL and use base +url-resolve.",
)
case 1:
out := map[string]interface{}{
"input_type": "title_query",
"resource_type": "bitable",
"title": candidates[0]["title"],
"base_token": candidates[0]["base_token"],
"url": candidates[0]["url"],
"owner_name": candidates[0]["owner_name"],
"update_time": candidates[0]["update_time"],
"hint": resolveHint("", nil),
}
runtime.OutFormat(out, nil, nil)
return nil
default:
runtime.OutFormat(map[string]interface{}{
"input_type": "title_query",
"resource_type": "bitable",
"candidates": candidates,
"hint": map[string]interface{}{
"next_step": baseTitleResolveHint,
},
}, nil, nil)
return nil
}
}
func enrichBaseResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
if baseToken == "" || tableID == "" {
out["hint"] = resolveHint("", nil)
return
}
fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100)
if err != nil {
out["hint"] = resolveHint(tableID, nil)
return
}
out["hint"] = resolveHint(tableID, map[string]interface{}{"fields": map[string]interface{}{"fields": fields, "total": total}})
}
func enrichRecordShareResolveHint(runtime *common.RuntimeContext, out map[string]interface{}) {
baseToken := strings.TrimSpace(common.GetString(out, "base_token"))
tableID := strings.TrimSpace(common.GetString(out, "table_id"))
recordID := strings.TrimSpace(common.GetString(out, "record_id"))
hint := map[string]interface{}{}
if baseToken != "" && tableID != "" && recordID != "" {
if record, err := getResolveRecord(runtime, baseToken, tableID, recordID); err == nil {
hint["record_data"] = formatResolvedRecordData(record)
}
}
if baseToken != "" && tableID != "" {
if fields, total, err := listAllFields(runtime, baseToken, tableID, 0, 100); err == nil {
hint["fields"] = map[string]interface{}{"fields": fields, "total": total}
}
}
out["hint"] = resolveHint(tableID, hint)
common.GetMap(out, "hint")["next_step"] = recordShareNextStep(baseToken, tableID, recordID)
}
func getResolveRecord(runtime *common.RuntimeContext, baseToken, tableID, recordID string) (map[string]interface{}, error) {
body := map[string]interface{}{"record_id_list": []string{recordID}}
result, err := baseV3Raw(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableID, "records", "batch_get"), nil, body)
return handleBaseAPIResult(result, err, "batch get records")
}
func formatResolvedRecordData(record map[string]interface{}) map[string]interface{} {
fieldIDs := common.GetSlice(record, "field_id_list")
fieldNames := common.GetSlice(record, "fields")
rows := common.GetSlice(record, "data")
data := map[string]interface{}{}
if len(rows) > 0 {
if values, ok := rows[0].([]interface{}); ok {
for i, value := range values {
data[resolvedRecordFieldKey(fieldIDs, fieldNames, i)] = value
}
}
}
return data
}
func resolvedRecordFieldKey(fieldIDs, fieldNames []interface{}, index int) string {
if index < len(fieldIDs) {
if fieldID := strings.TrimSpace(fmt.Sprintf("%v", fieldIDs[index])); fieldID != "" {
return fieldID
}
}
if index < len(fieldNames) {
if fieldName := strings.TrimSpace(fmt.Sprintf("%v", fieldNames[index])); fieldName != "" {
return fieldName
}
}
return fmt.Sprintf("field_%d", index+1)
}
func recordShareNextStep(baseToken, tableID, recordID string) string {
return fmt.Sprintf(`use +record-upsert --base-token %s --table-id %s --record-id %s --json '{"<field_id>":"<new_value>"}' to update this record`, baseToken, tableID, recordID)
}
func resolveHint(tableID string, extra map[string]interface{}) map[string]interface{} {
hint := map[string]interface{}{}
for key, value := range extra {
hint[key] = value
}
if strings.TrimSpace(tableID) != "" {
hint["next_step"] = nextStepRecordList
} else {
hint["next_step"] = nextStepBaseBlockList
}
return hint
}
func buildTitleResolveSearchBody(query string) map[string]interface{} {
filter := map[string]interface{}{"doc_types": []string{"BITABLE"}}
return map[string]interface{}{
"query": query,
"page_size": 5,
"doc_filter": filter,
"wiki_filter": filter,
}
}
func normalizeTitleResolveCandidates(items []interface{}) []map[string]interface{} {
candidates := make([]map[string]interface{}, 0, len(items))
for _, item := range items {
row, _ := item.(map[string]interface{})
meta, _ := row["result_meta"].(map[string]interface{})
if row == nil || meta == nil || strings.ToUpper(common.GetString(meta, "doc_types")) != "BITABLE" {
continue
}
token := strings.TrimSpace(common.GetString(meta, "token"))
if token == "" {
continue
}
title := stripSearchHighlight(common.GetString(row, "title_highlighted"))
if title == "" {
title = strings.TrimSpace(common.GetString(row, "title"))
}
candidates = append(candidates, map[string]interface{}{
"title": title,
"base_token": token,
"url": common.GetString(meta, "url"),
"owner_name": common.GetString(meta, "owner_name"),
"update_time": common.GetString(meta, "update_time_iso"),
})
}
return candidates
}
var searchHighlightTagRe = regexp.MustCompile(`</?h>`)
func stripSearchHighlight(s string) string {
return strings.TrimSpace(searchHighlightTagRe.ReplaceAllString(s, ""))
}
func resolveValidationError(message, hint string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", message).WithHint("%s", hint)
}
func normalizeResolvePath(path string) string {
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
func pathSegmentExists(path, prefix string) bool {
return firstPathSegmentAfter(path, prefix) != ""
}
func firstPathSegmentAfter(path, prefix string) string {
path = normalizeResolvePath(path)
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := path[len(prefix):]
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
rest = rest[:idx]
}
return strings.TrimSpace(rest)
}
func valueOrUnknown(s string) string {
if strings.TrimSpace(s) == "" {
return "an unknown resource type"
}
return s
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -0,0 +1,454 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestBaseURLResolveBaseURL(t *testing.T) {
t.Run("with coordinates", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve",
"--url", "https://example.larkoffice.com/base/bas123?table=tbl123&view=vew123&record=rec123",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if data["table_id"] != "tbl123" || data["view_id"] != "vew123" || data["record_id"] != "rec123" {
t.Fatalf("missing Base coordinates: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
fields, _ := hint["fields"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList || fields["total"] != float64(2) {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("base only", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "base_url" || data["base_token"] != "bas123" {
t.Fatalf("unexpected output: %#v", data)
}
if _, ok := data["table_id"]; ok {
t.Fatalf("table_id should be omitted for base-only URL: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepBaseBlockList {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("field list enrichment failure still returns coordinates", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/base/bas123?table=tbl123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["base_token"] != "bas123" || data["table_id"] != "tbl123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
if hint["next_step"] != nextStepRecordList {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
}
func TestBaseURLResolveWikiURL(t *testing.T) {
t.Run("bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wik123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "bitable",
"obj_token": "bas123",
"title": "Demo Base",
},
},
},
})
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wik123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "wiki_url" || data["base_token"] != "bas123" || data["title"] != "Demo Base" {
t.Fatalf("unexpected output: %#v", data)
}
})
t.Run("non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node?token=wikdoc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "docx", "obj_token": "docx123"},
},
},
})
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/wiki/wikdoc", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "not Base") {
t.Fatalf("err=%v, want non-Base validation error", err)
}
})
}
func TestBaseURLResolveRecordShareURL(t *testing.T) {
t.Run("enriched", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
reg.Register(recordBatchGetStub("bas123", "tbl123", "rec123"))
reg.Register(fieldListStub("bas123", "tbl123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
recordData, _ := hint["record_data"].(map[string]interface{})
fields, _ := hint["fields"].(map[string]interface{})
nextStep, _ := hint["next_step"].(string)
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") || recordData["fld_name"] != "Alice" || fields["total"] != float64(2) {
t.Fatalf("unexpected hint: %#v", hint)
}
})
t.Run("enrichment failure still returns meta", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(recordShareMetaStub("shr123", "bas123", "tbl123", "rec123"))
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.larkoffice.com/record/shr123", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "record_share_url" || data["base_token"] != "bas123" || data["record_id"] != "rec123" {
t.Fatalf("unexpected output: %#v", data)
}
hint, _ := data["hint"].(map[string]interface{})
nextStep, _ := hint["next_step"].(string)
if !strings.Contains(nextStep, "+record-upsert --base-token bas123 --table-id tbl123 --record-id rec123") {
t.Fatalf("unexpected hint: %#v", hint)
}
if _, ok := hint["record_data"]; ok {
t.Fatalf("record_data should be omitted when enrichment fails: %#v", hint)
}
if _, ok := hint["fields"]; ok {
t.Fatalf("fields should be omitted when enrichment fails: %#v", hint)
}
})
}
func recordShareMetaStub(shareToken, baseToken, tableID, recordID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/record_share/" + shareToken + "/meta",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_share_token": shareToken,
"base_token": baseToken,
"table_id": tableID,
"record_id": recordID,
},
},
}
}
func TestBaseURLResolveFormShareURL(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--query", "https://example.larkoffice.com/share/base/form/shrform", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["input_type"] != "form_share_url" || data["share_token"] != "shrform" {
t.Fatalf("unexpected output: %#v", data)
}
}
func TestBaseURLResolveValidationErrors(t *testing.T) {
tests := []struct {
name string
rawURL string
wantText string
wantHint string
}{
{"dashboard share", "https://example.larkoffice.com/share/base/dashboard/shr1", "CLI does not support resolving Base dashboard share URLs", "provide the URL of the Base itself"},
{"view share", "https://example.larkoffice.com/share/base/view/shr1", "CLI does not support resolving Base view share URLs", "provide the URL of the Base itself"},
{"workspace", "https://example.larkoffice.com/base/workspace/ws1", "CLI does not support resolving Base workspace URLs", "provide the URL of the Base itself"},
{"add record", "https://example.larkoffice.com/base/add/addtoken", "CLI does not support resolving Base add-record URLs", "provide the URL of the Base itself"},
{"unrelated", "https://example.larkoffice.com/docx/doc1", "not a supported Base URL pattern", ""},
{"not url", "bas123", "only accepts full URLs", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", tc.rawURL, "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantText) {
t.Fatalf("err=%v, want contains %q", err, tc.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Hint == "" {
t.Fatalf("err=%v, want typed error with hint", err)
}
if tc.wantHint != "" && !strings.Contains(p.Hint, tc.wantHint) {
t.Fatalf("hint=%q, want contains %q", p.Hint, tc.wantHint)
}
if strings.Contains(p.Hint, "original /base/{base_token}") {
t.Fatalf("hint should not require original /base URL: %q", p.Hint)
}
})
}
}
func TestBaseResolveInputXOR(t *testing.T) {
t.Run("url resolve", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseURLResolve, authTypes(), []string{
"+url-resolve", "--url", "https://example.com/base/bas1", "--query", "https://example.com/base/bas2", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v, want xor validation", err)
}
})
t.Run("title resolve", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "Pipeline", "--query", "Sales", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v, want xor validation", err)
}
})
}
func TestBaseResolveHelpFlags(t *testing.T) {
for _, tc := range []struct {
shortcut string
definition common.Shortcut
primaryFlag string
primaryDesc string
aliasFlags []string
}{
{
shortcut: "+url-resolve",
definition: BaseURLResolve,
primaryFlag: "url",
primaryDesc: "Base/Wiki/record-share URL to resolve",
aliasFlags: []string{"query"},
},
{
shortcut: "+title-resolve",
definition: BaseTitleResolve,
primaryFlag: "title",
primaryDesc: "Base title keyword",
aliasFlags: []string{"query", "url"},
},
} {
t.Run(tc.shortcut, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tc.definition.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
primary := cmd.Flags().Lookup(tc.primaryFlag)
primaryUsage := ""
if primary != nil {
primaryUsage = primary.Usage
}
if primary == nil || !strings.Contains(primaryUsage, tc.primaryDesc) {
t.Fatalf("primary flag %q usage=%q", tc.primaryFlag, primaryUsage)
}
for _, aliasFlag := range tc.aliasFlags {
alias := cmd.Flags().Lookup(aliasFlag)
if alias == nil || !alias.Hidden {
t.Fatalf("alias flag %q should exist and be hidden: %#v", aliasFlag, alias)
}
}
})
}
}
func TestBaseTitleResolve(t *testing.T) {
t.Run("single result", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub([]interface{}{
map[string]interface{}{
"title_highlighted": "Sales <h>Pipeline</h>",
"result_meta": map[string]interface{}{
"doc_types": "BITABLE",
"token": "bas123",
"url": "https://example.larkoffice.com/base/bas123",
"owner_name": "Alice",
"update_time_iso": "2026-06-09T10:00:00+08:00",
},
},
}))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "Pipeline", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
if data["title"] != "Sales Pipeline" || data["base_token"] != "bas123" || data["owner_name"] != "Alice" {
t.Fatalf("unexpected output: %#v", data)
}
})
t.Run("multiple results and filter non bitable", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub([]interface{}{
map[string]interface{}{
"title_highlighted": "Doc hit",
"result_meta": map[string]interface{}{"doc_types": "DOCX", "token": "docx123"},
},
map[string]interface{}{
"title_highlighted": "Base <h>One</h>",
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas1", "url": "https://example/base/bas1"},
},
map[string]interface{}{
"title_highlighted": "Base <h>Two</h>",
"result_meta": map[string]interface{}{"doc_types": "BITABLE", "token": "bas2", "url": "https://example/base/bas2"},
},
}))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--url", "Base", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("err=%v", err)
}
data := decodeBaseEnvelope(t, stdout)
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != 2 {
t.Fatalf("candidates=%#v, want 2", data["candidates"])
}
})
t.Run("no results", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(titleResolveSearchStub(nil))
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "missing", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "No Base matched") {
t.Fatalf("err=%v, want no result validation", err)
}
})
t.Run("query too long", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcutWithAuthTypes(t, BaseTitleResolve, nil, []string{
"+title-resolve", "--title", "codex record share resolve 20260616152113", "--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "30 characters or fewer") {
t.Fatalf("err=%v, want query length validation", err)
}
})
}
func titleResolveSearchStub(items []interface{}) *httpmock.Stub {
if items == nil {
items = []interface{}{}
}
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"res_units": items,
},
},
}
}
func fieldListStub(baseToken, tableID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"total": 2,
"fields": []interface{}{
map[string]interface{}{"field_id": "fld_name", "field_name": "Name", "type": "text"},
map[string]interface{}{"field_id": "fld_status", "field_name": "Status", "type": "singleSelect"},
},
},
},
}
}
func recordBatchGetStub(baseToken, tableID, recordID string) *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/" + baseToken + "/tables/" + tableID + "/records/batch_get",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id_list": []interface{}{recordID},
"field_id_list": []interface{}{"fld_name", "fld_status"},
"fields": []interface{}{"Name", "Status"},
"data": []interface{}{[]interface{}{"Alice", "Done"}},
},
},
}
}

View File

@@ -155,6 +155,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+url-resolve", "+title-resolve",
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",

View File

@@ -8,6 +8,8 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all base shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
BaseURLResolve,
BaseTitleResolve,
BaseBaseBlockList,
BaseBaseBlockCreate,
BaseBaseBlockMove,

View File

@@ -199,16 +199,7 @@ func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *te
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, tt.wantText)
})
}
}

View File

@@ -142,9 +142,7 @@ func TestNormalizeMCPToolResult(t *testing.T) {
got, err := normalizeMCPToolResult(tt.raw)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
requireProblem(t, err, errs.CategoryAPI, errs.SubtypeUnknown, tt.wantErr)
return
}
if err != nil {

View File

@@ -49,6 +49,7 @@ type RuntimeContext struct {
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
}
// ── Identity ──
@@ -223,6 +224,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
return v
}
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
func (ctx *RuntimeContext) IntArray(name string) []int {
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -1023,7 +1030,6 @@ func stripUTF8BOM(s string) string {
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
stdinUsed := false
for _, fl := range flags {
if len(fl.Input) == 0 {
continue
@@ -1043,11 +1049,14 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
}
if stdinUsed {
// A process has a single stdin, so we reject a second Input flag
// trying to use `-` after the first one has already consumed it.
if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--" + fl.Name)
WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
}
stdinUsed = true
rctx.stdinConsumed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
@@ -1160,7 +1169,13 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
hints = append(hints, "@file")
}
if slices.Contains(fl.Input, Stdin) {
hints = append(hints, "- for stdin")
// "- reads stdin" intentionally avoids implying each flag has
// its own stdin: a process has a single stdin, so at most one
// flag per call may use "-" (the rest must use @file). The old
// per-flag "- for stdin" wording led AI agents to write
// `--a - <x --b - <y`, where the second `<` silently clobbers
// the first and `--a` reads the wrong payload.
hints = append(hints, "- reads stdin (one flag per call; use @file for others)")
}
desc += " (supports " + strings.Join(hints, ", ") + ")"
}
@@ -1176,6 +1191,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "int_array":
cmd.Flags().IntSlice(fl.Name, nil, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":

View File

@@ -4,9 +4,12 @@
package common
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
@@ -19,6 +22,7 @@ func TestRejectPositionalArgs_WithArgs(t *testing.T) {
if err == nil {
t.Fatal("expected error for positional arg, got nil")
}
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("expected positional args rejection message, got: %v", err)
}
@@ -36,6 +40,7 @@ func TestRejectPositionalArgs_MultipleArgs(t *testing.T) {
if err == nil {
t.Fatal("expected error for multiple positional args, got nil")
}
// rejectPositionalArgs returns a raw fmt.Errorf via cobra's PositionalArgs contract — not a typed envelope, message-substring assertion is intentional.
if !strings.Contains(err.Error(), "positional arguments are not supported") {
t.Errorf("unexpected error message: %v", err)
}
@@ -56,3 +61,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}
func TestShortcutFlagIntArray(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
var got []int
shortcut := Shortcut{
Service: "slides",
Command: "+screenshot",
Description: "capture screenshots",
Flags: []Flag{
{Name: "slide-number", Type: "int_array"},
},
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
got = runtime.IntArray("slide-number")
return nil
},
}
shortcut.Mount(parent, f)
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
if err := parent.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
t.Fatalf("slide-number = %#v, want %#v", got, want)
}
}

View File

@@ -171,6 +171,7 @@ func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
if err == nil {
t.Fatal("expected error for non-zero code")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "[99991]") {
t.Errorf("error = %q, want substring [99991]", err.Error())
}
@@ -197,6 +198,7 @@ func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty open_id")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "open_id is empty") {
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
}
@@ -218,6 +220,7 @@ func TestFetchBotInfo_HTTP4xx(t *testing.T) {
if err == nil {
t.Fatal("expected error for HTTP 403")
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "403") {
t.Errorf("error = %q, want substring '403'", err.Error())
}
@@ -238,7 +241,7 @@ func TestFetchBotInfo_InvalidJSON(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid JSON")
}
// Error may come from SDK-level parse or our unmarshal wrapper
// Error may come from SDK-level parse or our unmarshal wrapper — both are raw fmt.Errorf, not a typed envelope.
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
t.Errorf("error = %q, want JSON parse failure", err.Error())
}
@@ -279,6 +282,7 @@ func TestFetchBotInfo_CanBotFalse(t *testing.T) {
if info != nil {
t.Errorf("expected nil info, got %+v", info)
}
// fetchBotInfo returns a raw fmt.Errorf, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error = %q, want substring 'not available'", err.Error())
}
@@ -291,6 +295,7 @@ func TestBotInfo_NilFunc(t *testing.T) {
if err == nil {
t.Fatal("expected error for nil botInfoFunc")
}
// BotInfo() returns a raw fmt.Errorf when botInfoFunc is nil, not a typed envelope — message-substring assertion is intentional.
if !strings.Contains(err.Error(), "not fully initialized") {
t.Errorf("unexpected error: %v", err)
}

View File

@@ -129,9 +129,9 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for stdin not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support stdin") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support stdin") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -143,9 +143,9 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for file not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support file input") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--data")
if !strings.Contains(vErr.Message, "does not support file input") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -160,9 +160,9 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "cannot read file") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -174,9 +174,9 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--markdown")
if !strings.Contains(vErr.Message, "file path cannot be empty after @") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
}
@@ -216,9 +216,14 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
assertValidationParam(t, err, "--b")
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error: %v", err)
vErr := assertValidationParam(t, err, "--b")
if !strings.Contains(vErr.Message, "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error message: %q", vErr.Message)
}
// The hint must steer an AI agent to the fix (@file for the extra flags),
// since `--a - <x --b - <y` is the exact misuse this guards against.
if !strings.Contains(vErr.Hint, "@file") {
t.Errorf("hint %q should mention @file as the fix", vErr.Hint)
}
}

View File

@@ -186,9 +186,7 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
if err == nil {
t.Fatal("expected error for --jq + --format table conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
requireValidation(t, err, "mutually exclusive")
}
func TestRunShortcut_JqInvalidExpression(t *testing.T) {
@@ -208,9 +206,7 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) {
if err == nil {
t.Fatal("expected error for invalid jq expression")
}
if !strings.Contains(err.Error(), "invalid jq expression") {
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
}
requireValidation(t, err, "invalid jq expression")
}
func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) {

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for CategoryValidation + SubtypeInvalidArgument.
// Returns *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime

View File

@@ -85,6 +85,7 @@ type searchUserAPIData struct {
Items []searchUserAPIItem `json:"items"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
Notice string `json:"notice"`
}
type searchUserAPIItem struct {
@@ -126,6 +127,7 @@ type searchUser struct {
type searchUserResponse struct {
Users []searchUser `json:"users"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
var ContactSearchUser = common.Shortcut{
@@ -189,6 +191,7 @@ var ContactSearchUser = common.Shortcut{
Execute: executeSearchUser,
}
// executeSearchUser dispatches contact search to single-query or fanout mode.
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("queries")) != "" {
return executeSearchUserFanout(ctx, runtime)
@@ -196,6 +199,7 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
return executeSearchUserSingle(ctx, runtime)
}
// executeSearchUserSingle performs one contact search and preserves server notices.
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildSearchUserBody(runtime)
if err != nil {
@@ -222,7 +226,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
out := searchUserResponse{Users: users, HasMore: hasMore}
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
if len(users) == 0 {

View File

@@ -45,22 +45,17 @@ type fanoutResult struct {
Query string
Users []searchUser
HasMore bool
Notice string
ErrMsg string // empty = success
Err error // original failure, kept for typed all-failed propagation
}
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
// because that summary lives on stderr and never corrupts the csv stream on
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
// for its refine hint, so adding csv here doesn't regress that path.
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
func isFanoutSummaryFormat(format string) bool {
return format == "pretty" || format == "table" || format == "csv"
}
// runOneQuery converts every failure mode (transport, HTTP status, parse,
// API code) into an ErrMsg string instead of returning a Go error. The
// fanout dispatcher (Task 6) relies on this so a single failed query never
// short-circuits the remaining workers.
// runOneQuery converts one fanout request into either users or an error summary.
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
filter *searchUserAPIFilter) fanoutResult {
// Pre-check ctx so queued workers see cancellation before issuing a
@@ -94,9 +89,10 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
}
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
}
// fanoutErrorResult records a failed fanout query without stopping other workers.
func fanoutErrorResult(index int, query string, err error) fanoutResult {
if err == nil {
return fanoutResult{Index: index, Query: query}
@@ -113,17 +109,16 @@ type querySummary struct {
Query string `json:"query"`
Error string `json:"error,omitempty"`
HasMore bool `json:"has_more"`
Notice string `json:"notice,omitempty"`
}
type fanoutResponse struct {
Users []fanoutUser `json:"users"`
Queries []querySummary `json:"queries"`
Notice string `json:"notice,omitempty"`
}
// buildFanoutResponse walks results by Index (input order), flattens users[]
// with matched_query, lists every input in queries[] (including successes),
// and returns an error only when every query failed. The error wraps the
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
indexed := make([]fanoutResult, len(queries))
for _, r := range results {
@@ -142,6 +137,7 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
Query: queries[i],
Error: r.ErrMsg,
HasMore: r.HasMore,
Notice: r.Notice,
})
if r.ErrMsg != "" {
failed++
@@ -152,6 +148,9 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
}
continue
}
if out.Notice == "" {
out.Notice = r.Notice
}
for _, u := range r.Users {
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
}

View File

@@ -562,6 +562,7 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
return parent.Execute()
}
// searchUserStub returns a representative user search response with a notice.
func searchUserStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
@@ -569,6 +570,7 @@ func searchUserStub() *httpmock.Stub {
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{
map[string]interface{}{
"id": "ou_a",
@@ -590,6 +592,7 @@ func searchUserStub() *httpmock.Stub {
}
}
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
@@ -614,6 +617,7 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
}
}
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(searchUserStub())
@@ -631,6 +635,9 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
if !ok {
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
users, _ := data["users"].([]interface{})
if len(users) != 1 {
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
@@ -1358,6 +1365,7 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
}
}
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
stub := &httpmock.Stub{
@@ -1399,6 +1407,7 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
}
}
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
reg.Register(&httpmock.Stub{
@@ -1406,6 +1415,7 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
Body: map[string]interface{}{"code": 0, "msg": "ok",
"data": map[string]interface{}{
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
"has_more": false,
}},
@@ -1432,10 +1442,17 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
if len(users) != 1 {
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
}
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("data.notice = %v", data["notice"])
}
queries := data["queries"].([]interface{})
if len(queries) != 2 {
t.Fatalf("queries: expected 2, got %d", len(queries))
}
q0 := queries[0].(map[string]interface{})
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
t.Fatalf("queries[0].notice = %v", q0["notice"])
}
q1 := queries[1].(map[string]interface{})
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
t.Errorf("queries[1].error: got %q", q1["error"])

View File

@@ -55,7 +55,7 @@ var docCoverAllowedContentTypes = map[string]string{
var DocResourceDownload = common.Shortcut{
Service: "docs",
Command: "resource-download",
Command: "+resource-download",
Description: "Download a document resource (type=cover downloads the cover image content)",
Risk: "read",
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
@@ -154,7 +154,7 @@ var DocResourceDownload = common.Shortcut{
var DocResourceUpdate = common.Shortcut{
Service: "docs",
Command: "resource-update",
Command: "+resource-update",
Description: "Upload and update a document resource (type=cover)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
@@ -256,7 +256,7 @@ var DocResourceUpdate = common.Shortcut{
var DocResourceDelete = common.Shortcut{
Service: "docs",
Command: "resource-delete",
Command: "+resource-delete",
Description: "Delete a document resource (type=cover is idempotent when empty)",
Risk: "write",
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},

View File

@@ -48,7 +48,7 @@ func TestDocResourceDownloadCoverDownloadsImageContent(t *testing.T) {
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"resource-download",
"+resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover",
@@ -95,7 +95,7 @@ func TestDocResourceDownloadCoverEmptyReturnsErrorWithoutDownload(t *testing.T)
withDocsWorkingDir(t, tmpDir)
err := mountAndRunDocs(t, DocResourceDownload, []string{
"resource-download",
"+resource-download",
"--doc", documentID,
"--type", "cover",
"--output", "cover.png",
@@ -116,7 +116,7 @@ func TestDocResourceDeleteCoverEmptyIsIdempotent(t *testing.T) {
reg.Register(docCoverMetadataStub(documentID, map[string]interface{}{}))
err := mountAndRunDocs(t, DocResourceDelete, []string{
"resource-delete",
"+resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
@@ -146,7 +146,7 @@ func TestDocResourceDeleteCoverClearsExistingCover(t *testing.T) {
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceDelete, []string{
"resource-delete",
"+resource-delete",
"--doc", documentID,
"--type", "cover",
"--as", "bot",
@@ -195,7 +195,7 @@ func TestDocResourceUpdateCoverUploadsFileAndReturnsFullTokenOnlyOnStdout(t *tes
reg.Register(patchStub)
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"+resource-update",
"--doc", documentID,
"--type", "cover",
"--file", "cover.png",
@@ -241,7 +241,7 @@ func TestDocResourceUpdateCoverRejectsMultipleSources(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"+resource-update",
"--doc", "doxcnCoverValidate1",
"--type", "cover",
"--file", "cover.png",
@@ -258,7 +258,7 @@ func TestDocResourceUpdateCoverRejectsMissingSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-source-required-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"+resource-update",
"--doc", "doxcnCoverValidateRequired1",
"--type", "cover",
"--as", "bot",
@@ -273,7 +273,7 @@ func TestDocResourceUpdateCoverRejectsUnsafeURLSource(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-cover-url-validation-app"))
err := mountAndRunDocs(t, DocResourceUpdate, []string{
"resource-update",
"+resource-update",
"--doc", "doxcnCoverURLValidate1",
"--type", "cover",
"--url", "https://127.0.0.1/cover.png",
@@ -617,7 +617,7 @@ func TestDocShortcutsIncludeCoverResourceCommands(t *testing.T) {
for _, shortcut := range Shortcuts() {
got[shortcut.Command] = true
}
for _, want := range []string{"resource-download", "resource-update", "resource-delete"} {
for _, want := range []string{"+resource-download", "+resource-update", "+resource-delete"} {
if !got[want] {
t.Fatalf("Shortcuts() missing %s", want)
}

View File

@@ -0,0 +1,861 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"html"
"net/url"
"regexp"
"strings"
"unicode/utf8"
)
type imMarkdownContext struct {
baseURL string
blockquoteDepth int
}
type imMarkdownHandleFunc func(segment, inner string, attrs map[string]string, imCtx imMarkdownContext) string
type imMarkdownTagHandler struct {
closeRE *regexp.Regexp
handle imMarkdownHandleFunc
}
func registerIMMarkdownHandler(tag string, handle imMarkdownHandleFunc) {
imMarkdownHandlers[tag] = imMarkdownTagHandler{
closeRE: regexp.MustCompile(`(?is)<(/?)` + regexp.QuoteMeta(tag) + `(?:\s[^<>]*?)?\s*/?>`),
handle: handle,
}
}
var (
imMarkdownTagStartRE = regexp.MustCompile(`(?s)<([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?\s*/?>`)
imMarkdownAttrRE = regexp.MustCompile(`([A-Za-z_:][A-Za-z0-9_:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')`)
imMarkdownRowTagRE = regexp.MustCompile(`(?is)<(/?)tr\b[^>]*?\s*/?>`)
imMarkdownCellTagRE = regexp.MustCompile(`(?is)<(/?)t[dh]\b[^>]*?\s*/?>`)
imMarkdownCellBreakRE = regexp.MustCompile(`(?i)<br\s*/?>`)
imMarkdownAnyTagRE = regexp.MustCompile(`(?s)</?([A-Za-z][A-Za-z0-9:_-]*)(?:\s[^<>]*?)?>`)
imMarkdownLinkRE = regexp.MustCompile(`(?is)<a\b[^>]*\bhref=(?:"([^"]*)"|'([^']*)')[^>]*>(.*?)</a>`)
imMarkdownCodeBlockRE = regexp.MustCompile(`(?is)^\s*<code(?:\s[^<>]*?)?>(.*?)</code>\s*$`)
imMarkdownLiOpenRE = regexp.MustCompile(`(?is)<li(?:\s[^<>]*?)?>`)
imMarkdownLiCloseRE = regexp.MustCompile(`(?is)<(/?)li(?:\s[^<>]*?)?\s*/?>`)
)
var imMarkdownHandlers = map[string]imMarkdownTagHandler{}
func init() {
registerIMMarkdownHandler("title", handleIMMarkdownTitle)
for level := 1; level <= 9; level++ {
registerIMMarkdownHandler(fmt.Sprintf("h%d", level), handleIMMarkdownHeading(level))
}
registerIMMarkdownHandler("p", handleIMMarkdownParagraph)
registerIMMarkdownHandler("ul", handleIMMarkdownUnorderedList)
registerIMMarkdownHandler("ol", handleIMMarkdownOrderedList)
registerIMMarkdownHandler("li", handleIMMarkdownListItem)
registerIMMarkdownHandler("callout", handleIMMarkdownCallout)
registerIMMarkdownHandler("blockquote", handleIMMarkdownBlockquote)
registerIMMarkdownHandler("grid", handleIMMarkdownPassthroughContainer)
registerIMMarkdownHandler("column", handleIMMarkdownColumn)
registerIMMarkdownHandler("table", handleIMMarkdownTable)
registerIMMarkdownHandler("colgroup", handleIMMarkdownDiscard)
registerIMMarkdownHandler("col", handleIMMarkdownDiscard)
registerIMMarkdownHandler("pre", handleIMMarkdownPre)
registerIMMarkdownHandler("code", handleIMMarkdownCode)
registerIMMarkdownHandler("latex", handleIMMarkdownLatex)
registerIMMarkdownHandler("hr", handleIMMarkdownHorizontalRule)
registerIMMarkdownHandler("img", handleIMMarkdownImage)
registerIMMarkdownHandler("figure", handleIMMarkdownDiscard)
registerIMMarkdownHandler("source", handleIMMarkdownSource)
registerIMMarkdownHandler("button", handleIMMarkdownDiscard)
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)
registerIMMarkdownHandler("folder_manager", handleIMMarkdownDiscard)
registerIMMarkdownHandler("wiki_catalog", handleIMMarkdownDiscard)
registerIMMarkdownHandler("wiki_recent_update", handleIMMarkdownDiscard)
registerIMMarkdownHandler("chart_refer_host_perm", handleIMMarkdownDiscard)
registerIMMarkdownHandler("synced_reference", handleIMMarkdownDiscard)
registerIMMarkdownHandler("synced-source", handleIMMarkdownDiscard)
registerIMMarkdownHandler("mindnote", handleIMMarkdownDiscard)
registerIMMarkdownHandler("bookmark", handleIMMarkdownBookmark)
registerIMMarkdownHandler("cite", handleIMMarkdownCite)
registerIMMarkdownHandler("b", handleIMMarkdownStrong)
registerIMMarkdownHandler("em", handleIMMarkdownEmphasis)
registerIMMarkdownHandler("del", handleIMMarkdownDelete)
registerIMMarkdownHandler("u", handleIMMarkdownPlainInline)
registerIMMarkdownHandler("span", handleIMMarkdownPlainInline)
registerIMMarkdownHandler("a", handleIMMarkdownAnchor)
}
func isIMMarkdownFetch(runtime interface{ Str(string) string }) bool {
return strings.TrimSpace(runtime.Str("doc-format")) == "im-markdown"
}
func applyFetchIMMarkdown(data map[string]interface{}, docInput string) {
doc, ok := data["document"].(map[string]interface{})
if !ok {
return
}
content, ok := doc["content"].(string)
if !ok {
return
}
doc["content"] = convertToIMMarkdown(content, newIMMarkdownContext(docInput))
}
func newIMMarkdownContext(docInput string) imMarkdownContext {
base := "https://larkoffice.com"
raw := strings.TrimSpace(docInput)
if extracted, ok := imMarkdownBaseURLFromInput(raw); ok {
base = extracted
}
return imMarkdownContext{baseURL: base}
}
func (c imMarkdownContext) withBlockquote() imMarkdownContext {
c.blockquoteDepth++
return c
}
func (c imMarkdownContext) inBlockquote() bool {
return c.blockquoteDepth > 0
}
// imMarkdownBaseURLFromInput keeps the tenant host from --doc when it is a URL
// so generated doc/sheet links point back to the same tenant. parseDocumentRef
// intentionally strips host information, so it cannot serve this formatting path.
func imMarkdownBaseURLFromInput(raw string) (string, bool) {
if raw == "" {
return "", false
}
if u, err := url.Parse(raw); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host, true
}
for _, marker := range []string{"/docx/", "/wiki/", "/doc/"} {
idx := strings.Index(raw, marker)
if idx <= 0 {
continue
}
candidate := strings.Trim(raw[:idx], "/")
if candidate == "" {
continue
}
if u, err := url.Parse(candidate); err == nil && u.Scheme != "" && u.Host != "" {
return u.Scheme + "://" + u.Host, true
}
if u, err := url.Parse("https://" + candidate); err == nil && u.Host != "" && strings.Contains(u.Host, ".") {
return "https://" + u.Host, true
}
}
return "", false
}
func convertToIMMarkdown(content string, imCtx imMarkdownContext) string {
var out strings.Builder
for offset := 0; offset < len(content); {
// Scan only to the next XML-like opening tag. Plain Markdown text between
// registered tags is copied unchanged, so ordinary Markdown is not re-parsed.
loc := imMarkdownTagStartRE.FindStringSubmatchIndex(content[offset:])
if loc == nil {
out.WriteString(content[offset:])
break
}
start := offset + loc[0]
openEnd := offset + loc[1]
tag := strings.ToLower(content[offset+loc[2] : offset+loc[3]])
handler, ok := imMarkdownHandlers[tag]
if !ok {
// Unknown tags are left intact. im-markdown only downgrades tags with
// explicit handlers so future server output does not get guessed at.
out.WriteString(content[offset:openEnd])
offset = openEnd
continue
}
out.WriteString(content[offset:start])
opening := content[start:openEnd]
attrs := parseIMMarkdownAttrs(opening)
if isSelfClosingIMMarkdownTag(opening) {
out.WriteString(handler.handle(opening, "", attrs, imCtx))
offset = openEnd
continue
}
// Use the handler's precompiled close regexp to find the matching end tag.
// Depth tracking keeps nested same-name containers paired correctly.
closeStart, closeEnd, found := findIMMarkdownClosingTag(content, openEnd, handler)
if !found {
// Malformed or truncated fragments are preserved as-is from the opening
// tag onward; do not drop content when the XML-ish structure is incomplete.
out.WriteString(content[start:])
break
}
segment := content[start:closeEnd]
inner := content[openEnd:closeStart]
out.WriteString(handler.handle(segment, inner, attrs, imCtx))
offset = closeEnd
}
return out.String()
}
func findIMMarkdownClosingTag(content string, from int, handler imMarkdownTagHandler) (int, int, bool) {
depth := 1
for _, loc := range handler.closeRE.FindAllStringSubmatchIndex(content[from:], -1) {
start := from + loc[0]
end := from + loc[1]
token := content[start:end]
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
depth--
if depth == 0 {
return start, end, true
}
continue
}
if !isSelfClosingIMMarkdownTag(token) {
depth++
}
}
return 0, 0, false
}
func parseIMMarkdownAttrs(opening string) map[string]string {
attrs := map[string]string{}
for _, match := range imMarkdownAttrRE.FindAllStringSubmatch(opening, -1) {
value := match[2]
if value == "" {
value = match[3]
}
attrs[strings.ToLower(match[1])] = html.UnescapeString(value)
}
return attrs
}
func isSelfClosingIMMarkdownTag(tag string) bool {
return strings.HasSuffix(strings.TrimSpace(tag), "/>")
}
func handleIMMarkdownTitle(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if text == "" {
return ""
}
return "# " + text
}
func handleIMMarkdownHeading(level int) imMarkdownHandleFunc {
return func(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
text := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if text == "" {
return ""
}
markdownLevel := level
if markdownLevel > 6 {
markdownLevel = 6
}
return strings.Repeat("#", markdownLevel) + " " + text
}
}
func handleIMMarkdownParagraph(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
if imCtx.inBlockquote() {
return body + "\n"
}
return body
}
func handleIMMarkdownUnorderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return convertIMMarkdownListItems(inner, false, imCtx)
}
func handleIMMarkdownOrderedList(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return convertIMMarkdownListItems(inner, true, imCtx)
}
func handleIMMarkdownListItem(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
prefix := "-"
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
prefix = strings.TrimSuffix(seq, ".") + "."
}
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return prefix + " " + indentIMMarkdownListContinuation(body) + "\n"
}
func handleIMMarkdownCallout(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
emoji := strings.TrimSpace(attrs["emoji"])
if emoji != "" {
if body == "" {
body = emoji
} else {
body = emoji + " " + body
}
}
if body == "" {
return "---\n---"
}
return fmt.Sprintf("---\n%s\n---", body)
}
func handleIMMarkdownBlockquote(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx.withBlockquote()))
if body == "" {
return ""
}
lines := strings.Split(body, "\n")
for i, line := range lines {
if strings.TrimSpace(line) == "" {
lines[i] = ">"
continue
}
lines[i] = "> " + line
}
return strings.Join(lines, "\n")
}
func handleIMMarkdownPassthroughContainer(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
}
func handleIMMarkdownColumn(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return body + "\n"
}
func handleIMMarkdownDiscard(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
return ""
}
func handleIMMarkdownInlineCode(segment string, _ string, _ map[string]string, _ imMarkdownContext) string {
return imMarkdownInlineCode(segment)
}
func handleIMMarkdownPre(_ string, inner string, attrs map[string]string, _ imMarkdownContext) string {
lang := strings.TrimSpace(attrs["lang"])
code := strings.TrimSpace(inner)
if match := imMarkdownCodeBlockRE.FindStringSubmatch(code); match != nil {
code = match[1]
}
return imMarkdownFencedCode(html.UnescapeString(code), lang)
}
func handleIMMarkdownCode(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
return imMarkdownInlineCode(markdownPlainText(inner))
}
func handleIMMarkdownLatex(_ string, inner string, _ map[string]string, _ imMarkdownContext) string {
expr := strings.TrimSpace(markdownPlainText(inner))
if expr == "" {
return ""
}
return "$" + strings.ReplaceAll(expr, "$", `\$`) + "$"
}
func handleIMMarkdownHorizontalRule(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
return "---"
}
func handleIMMarkdownImage(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
href := firstNonEmpty(attrs["href"], attrs["src"], attrs["url"])
if href == "" {
return ""
}
alt := firstNonEmpty(attrs["alt"], attrs["name"], attrs["title"])
return fmt.Sprintf("![%s](%s)", escapeMarkdownLinkText(alt), escapeMarkdownLinkDestination(href))
}
func handleIMMarkdownSource(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
name := strings.TrimSpace(attrs["name"])
if name == "" {
return ""
}
return imMarkdownInlineCode(name)
}
func handleIMMarkdownResourceLabel(label string) imMarkdownHandleFunc {
return func(_ string, _ string, _ map[string]string, _ imMarkdownContext) string {
return imMarkdownInlineCode(label)
}
}
func handleIMMarkdownConditionalResourceLabel(label string, attrNames ...string) imMarkdownHandleFunc {
return func(_ string, _ string, attrs map[string]string, _ imMarkdownContext) string {
for _, attrName := range attrNames {
if strings.TrimSpace(attrs[attrName]) != "" {
return imMarkdownInlineCode(label)
}
}
return ""
}
}
func handleIMMarkdownSheet(segment string, _ string, attrs map[string]string, imCtx imMarkdownContext) string {
token := strings.TrimSpace(attrs["token"])
if token == "" {
return imMarkdownInlineCode(segment)
}
label := "sheet"
if sheetID := strings.TrimSpace(attrs["sheet-id"]); sheetID != "" {
label = "sheet " + sheetID
}
return markdownLink(label, strings.TrimRight(imCtx.baseURL, "/")+"/sheets/"+token)
}
func handleIMMarkdownBookmark(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
href := strings.TrimSpace(attrs["href"])
name := firstNonEmpty(attrs["name"], attrs["title"], markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), href)
if href == "" {
return name
}
return markdownLink(name, href)
}
func handleIMMarkdownStrong(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return "**" + body + "**"
}
func handleIMMarkdownEmphasis(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return "*" + body + "*"
}
func handleIMMarkdownDelete(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
body := strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
if body == "" {
return ""
}
return "~~" + body + "~~"
}
func handleIMMarkdownPlainInline(_ string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
return strings.TrimSpace(convertToIMMarkdown(inner, imCtx))
}
func handleIMMarkdownAnchor(_ string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
href := strings.TrimSpace(attrs["href"])
text := firstNonEmpty(markdownLinkLabelText(convertToIMMarkdown(inner, imCtx)), attrs["name"], attrs["title"], href)
if href == "" {
return text
}
return markdownLink(text, href)
}
func handleIMMarkdownCite(segment string, inner string, attrs map[string]string, imCtx imMarkdownContext) string {
switch strings.ToLower(strings.TrimSpace(attrs["type"])) {
case "user":
userID := firstNonEmpty(attrs["user-id"], attrs["open-id"], attrs["id"])
name := firstNonEmpty(attrs["user-name"], attrs["name"], markdownPlainText(inner), userID)
if userID == "" {
return name
}
return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(name))
case "doc":
title := firstNonEmpty(attrs["title"], attrs["name"], attrs["doc-id"], "document")
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
return markdownLink(title, href)
}
docID := firstNonEmpty(attrs["doc-id"], attrs["token"])
if docID == "" {
return imMarkdownInlineCode(segment)
}
fileType := strings.Trim(strings.ToLower(firstNonEmpty(attrs["file-type"], "docx")), "/")
return markdownLink(title, strings.TrimRight(imCtx.baseURL, "/")+"/"+fileType+"/"+docID)
case "citation":
if text, href, ok := extractIMMarkdownInnerLink(inner); ok {
return markdownLink(text, href)
}
if href := firstNonEmpty(attrs["href"], attrs["url"]); href != "" {
return markdownLink(firstNonEmpty(attrs["title"], attrs["name"], href), href)
}
return markdownPlainText(convertToIMMarkdown(inner, imCtx))
default:
return imMarkdownInlineCode(segment)
}
}
func handleIMMarkdownTable(segment string, inner string, _ map[string]string, imCtx imMarkdownContext) string {
// Rows and cells are matched with tag-depth tracking instead of non-greedy
// regex captures. A table nested inside a cell can contain its own </tr> and
// </td>; treating those as the outer row/cell boundary corrupts the table.
rowBodies := extractIMMarkdownElementBodies(inner, imMarkdownRowTagRE)
if len(rowBodies) == 0 {
return imMarkdownInlineCode(segment)
}
rows := make([][]string, 0, len(rowBodies))
for _, rowBody := range rowBodies {
cellBodies := extractIMMarkdownElementBodies(rowBody, imMarkdownCellTagRE)
if len(cellBodies) == 0 {
continue
}
row := make([]string, 0, len(cellBodies))
for _, cellBody := range cellBodies {
row = append(row, normalizeIMMarkdownTableCell(convertToIMMarkdown(cellBody, imCtx)))
}
rows = append(rows, row)
}
if len(rows) == 0 {
return imMarkdownInlineCode(segment)
}
cols := 0
for _, row := range rows {
if len(row) > cols {
cols = len(row)
}
}
var out strings.Builder
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(rows[0], cols))
separator := make([]string, cols)
for i := range separator {
separator[i] = "-"
}
writeIMMarkdownTableRow(&out, separator)
for _, row := range rows[1:] {
writeIMMarkdownTableRow(&out, padIMMarkdownTableRow(row, cols))
}
return strings.TrimRight(out.String(), "\n")
}
// extractIMMarkdownElementBodies returns the inner content of each top-level
// element matched by tagRE. tagRE must expose the optional closing slash as its
// first capture group, matching the row/cell regexes above.
func extractIMMarkdownElementBodies(content string, tagRE *regexp.Regexp) []string {
var bodies []string
for offset := 0; offset < len(content); {
loc := tagRE.FindStringSubmatchIndex(content[offset:])
if loc == nil {
break
}
openStart := offset + loc[0]
openEnd := offset + loc[1]
opening := content[openStart:openEnd]
if loc[2] >= 0 && content[offset+loc[2]:offset+loc[3]] == "/" {
offset = openEnd
continue
}
if isSelfClosingIMMarkdownTag(opening) {
bodies = append(bodies, "")
offset = openEnd
continue
}
closeStart, closeEnd, found := findIMMarkdownElementClosingTag(content, openEnd, tagRE)
if !found {
break
}
bodies = append(bodies, content[openEnd:closeStart])
offset = closeEnd
}
return bodies
}
func findIMMarkdownElementClosingTag(content string, from int, tagRE *regexp.Regexp) (int, int, bool) {
depth := 1
for _, loc := range tagRE.FindAllStringSubmatchIndex(content[from:], -1) {
start := from + loc[0]
end := from + loc[1]
token := content[start:end]
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
depth--
if depth == 0 {
return start, end, true
}
continue
}
if !isSelfClosingIMMarkdownTag(token) {
depth++
}
}
return 0, 0, false
}
func normalizeIMMarkdownTableCell(cell string) string {
const brPlaceholder = "\x00BR\x00"
cell = imMarkdownCellBreakRE.ReplaceAllString(cell, brPlaceholder)
cell = imMarkdownAnyTagRE.ReplaceAllStringFunc(cell, func(tag string) string {
name := strings.ToLower(strings.TrimPrefix(imMarkdownAnyTagRE.FindStringSubmatch(tag)[1], "/"))
if name == "at" {
return tag
}
return ""
})
cell = html.UnescapeString(cell)
cell = strings.ReplaceAll(cell, brPlaceholder, "<br>")
cell = strings.ReplaceAll(cell, " \n", "<br>")
cell = strings.ReplaceAll(cell, "\n", "<br>")
cell = strings.ReplaceAll(cell, "|", `\|`)
lines := strings.Fields(cell)
if len(lines) == 0 {
return ""
}
return strings.Join(lines, " ")
}
func writeIMMarkdownTableRow(out *strings.Builder, row []string) {
out.WriteString("| ")
out.WriteString(strings.Join(row, " | "))
out.WriteString(" |\n")
}
func padIMMarkdownTableRow(row []string, cols int) []string {
if len(row) >= cols {
return row
}
padded := make([]string, cols)
copy(padded, row)
return padded
}
func convertIMMarkdownListItems(inner string, ordered bool, imCtx imMarkdownContext) string {
var out strings.Builder
for offset, index := 0, 1; offset < len(inner); {
loc := imMarkdownLiOpenRE.FindStringIndex(inner[offset:])
if loc == nil {
break
}
openStart := offset + loc[0]
openEnd := offset + loc[1]
opening := inner[openStart:openEnd]
closeStart, closeEnd, found := findIMMarkdownListItemClosingTag(inner, openEnd)
if !found {
break
}
body := strings.TrimSpace(convertToIMMarkdown(inner[openEnd:closeStart], imCtx))
if body != "" {
prefix := "-"
if ordered {
attrs := parseIMMarkdownAttrs(opening)
if seq := strings.TrimSpace(attrs["seq"]); seq != "" && seq != "auto" {
prefix = strings.TrimSuffix(seq, ".") + "."
} else {
prefix = fmt.Sprintf("%d.", index)
}
index++
}
out.WriteString(prefix)
out.WriteString(" ")
out.WriteString(indentIMMarkdownListContinuation(body))
out.WriteString("\n")
}
offset = closeEnd
}
return strings.TrimRight(out.String(), "\n")
}
func findIMMarkdownListItemClosingTag(content string, from int) (int, int, bool) {
depth := 1
for _, loc := range imMarkdownLiCloseRE.FindAllStringSubmatchIndex(content[from:], -1) {
start := from + loc[0]
end := from + loc[1]
token := content[start:end]
if loc[2] >= 0 && content[from+loc[2]:from+loc[3]] == "/" {
depth--
if depth == 0 {
return start, end, true
}
continue
}
if !isSelfClosingIMMarkdownTag(token) {
depth++
}
}
return 0, 0, false
}
func indentIMMarkdownListContinuation(body string) string {
return strings.ReplaceAll(body, "\n", "\n ")
}
func extractIMMarkdownInnerLink(inner string) (string, string, bool) {
match := imMarkdownLinkRE.FindStringSubmatch(inner)
if match == nil {
return "", "", false
}
href := match[1]
if href == "" {
href = match[2]
}
text := strings.TrimSpace(markdownPlainText(match[3]))
if text == "" {
text = href
}
return text, html.UnescapeString(href), true
}
func markdownPlainText(s string) string {
s = imMarkdownCellBreakRE.ReplaceAllString(s, "\n")
s = imMarkdownAnyTagRE.ReplaceAllString(s, "")
return strings.TrimSpace(html.UnescapeString(s))
}
func markdownLinkLabelText(s string) string {
text := markdownPlainText(s)
if !strings.Contains(text, "---") {
return text
}
lines := strings.Split(text, "\n")
kept := lines[:0]
for _, line := range lines {
if strings.TrimSpace(line) == "---" {
continue
}
kept = append(kept, line)
}
return strings.TrimSpace(strings.Join(kept, "\n"))
}
func markdownLink(text, href string) string {
cleanHref := strings.TrimSpace(href)
return fmt.Sprintf("[%s](%s)", escapeMarkdownLinkText(firstNonEmpty(text, cleanHref)), escapeMarkdownLinkDestination(cleanHref))
}
func escapeMarkdownLinkText(text string) string {
text = strings.ReplaceAll(text, `\`, `\\`)
text = strings.ReplaceAll(text, `[`, `\[`)
text = strings.ReplaceAll(text, `]`, `\]`)
return text
}
func escapeMarkdownLinkDestination(href string) string {
// Lark/Feishu IM Markdown does not reliably parse raw spaces or parentheses
// inside (...). Keep URL delimiters like :/?#&= intact, but percent-encode
// characters that can terminate or split the Markdown link destination.
var out strings.Builder
out.Grow(len(href))
for i := 0; i < len(href); {
if href[i] == '%' {
if i+2 < len(href) && isHexDigit(href[i+1]) && isHexDigit(href[i+2]) {
out.WriteString(href[i : i+3])
i += 3
} else {
writePercentEncodedByte(&out, href[i])
i++
}
continue
}
if href[i] < utf8.RuneSelf {
if shouldPercentEncodeIMMarkdownURLByte(href[i]) {
writePercentEncodedByte(&out, href[i])
} else {
out.WriteByte(href[i])
}
i++
continue
}
r, size := utf8.DecodeRuneInString(href[i:])
if r == utf8.RuneError && size == 1 {
writePercentEncodedByte(&out, href[i])
i++
continue
}
for _, b := range []byte(href[i : i+size]) {
writePercentEncodedByte(&out, b)
}
i += size
}
return out.String()
}
func shouldPercentEncodeIMMarkdownURLByte(b byte) bool {
if b <= ' ' || b >= 0x7f {
return true
}
switch b {
case '(', ')', '<', '>', '"', '\\', '^', '`', '{', '|', '}':
return true
default:
return false
}
}
func writePercentEncodedByte(out *strings.Builder, b byte) {
const hex = "0123456789ABCDEF"
out.WriteByte('%')
out.WriteByte(hex[b>>4])
out.WriteByte(hex[b&0x0f])
}
func isHexDigit(b byte) bool {
return ('0' <= b && b <= '9') || ('a' <= b && b <= 'f') || ('A' <= b && b <= 'F')
}
func imMarkdownInlineCode(s string) string {
maxRun := 0
run := 0
for _, r := range s {
if r == '`' {
run++
if run > maxRun {
maxRun = run
}
continue
}
run = 0
}
fence := strings.Repeat("`", maxRun+1)
if strings.HasPrefix(s, "`") || strings.HasSuffix(s, "`") {
return fence + " " + s + " " + fence
}
return fence + s + fence
}
func imMarkdownFencedCode(code, lang string) string {
maxRun := 0
for _, line := range strings.Split(code, "\n") {
if run := leadingBacktickRun(line); run > maxRun {
maxRun = run
}
}
fenceLen := maxRun + 1
if fenceLen < 3 {
fenceLen = 3
}
fence := strings.Repeat("`", fenceLen)
return fence + strings.TrimSpace(lang) + "\n" + strings.Trim(code, "\n") + "\n" + fence
}
func leadingBacktickRun(s string) int {
run := 0
for _, r := range s {
if r != '`' {
break
}
run++
}
return run
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ import (
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export, im-markdown downgrades residual DocxXML fragments for IM messages", Default: "xml", Enum: []string{"xml", "markdown", "im-markdown"}},
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "lang", Desc: "user cite display language, e.g. en-US, zh-CN, ja-JP"},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
@@ -72,6 +72,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning)
}
if isIMMarkdownFetch(runtime) {
applyFetchIMMarkdown(data, runtime.Str("doc"))
}
runtime.OutFormatRaw(data, nil, func(w io.Writer) {
if doc, ok := data["document"].(map[string]interface{}); ok {
@@ -85,7 +88,7 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"format": runtime.Str("doc-format"),
"format": effectiveFetchFormat(runtime),
}
if v := runtime.Int("revision-id"); v > 0 {
body["revision_id"] = v
@@ -122,6 +125,14 @@ func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} {
return body
}
func effectiveFetchFormat(runtime *common.RuntimeContext) string {
format := strings.TrimSpace(runtime.Str("doc-format"))
if format == "im-markdown" {
return "markdown"
}
return format
}
func resolveFetchLang(runtime *common.RuntimeContext) string {
if runtime.Changed("lang") {
return strings.TrimSpace(runtime.Str("lang"))

View File

@@ -6,9 +6,12 @@ package doc
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -104,6 +107,369 @@ func TestBuildFetchBodyExplicitBlankLangOmitsLang(t *testing.T) {
}
}
func TestBuildFetchBodyIncludesRevisionAndFullDetail(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "revision-id", "42")
mustSetFetchFlag(t, runtime, "detail", "full")
body := buildFetchBody(runtime)
if got := body["revision_id"]; got != 42 {
t.Fatalf("revision_id = %#v, want 42", got)
}
exportOption, _ := body["export_option"].(map[string]interface{})
want := map[string]interface{}{
"export_block_id": true,
"export_style_attrs": true,
"export_cite_extra_data": true,
}
if !reflect.DeepEqual(exportOption, want) {
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
}
}
func TestBuildFetchBodyIncludesWithIDsDetail(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "detail", "with-ids")
body := buildFetchBody(runtime)
exportOption, _ := body["export_option"].(map[string]interface{})
want := map[string]interface{}{
"export_block_id": true,
}
if !reflect.DeepEqual(exportOption, want) {
t.Fatalf("export_option = %#v, want %#v", exportOption, want)
}
}
func TestBuildFetchBodyIncludesReadOption(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
mustSetFetchFlag(t, runtime, "scope", "section")
mustSetFetchFlag(t, runtime, "start-block-id", "blk_heading")
body := buildFetchBody(runtime)
want := map[string]interface{}{
"read_mode": "section",
"start_block_id": "blk_heading",
}
if got := body["read_option"]; !reflect.DeepEqual(got, want) {
t.Fatalf("read_option = %#v, want %#v", got, want)
}
}
func TestBuildReadOptionModes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
want map[string]interface{}
}{
{
name: "full omits read option",
setFlags: map[string]string{
"scope": "full",
},
want: nil,
},
{
name: "outline with max depth",
setFlags: map[string]string{
"scope": "outline",
"max-depth": "3",
},
want: map[string]interface{}{
"read_mode": "outline",
"max_depth": "3",
},
},
{
name: "range with block ids and context",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"end-block-id": "blk_end",
"context-before": "2",
"context-after": "1",
"max-depth": "0",
},
want: map[string]interface{}{
"read_mode": "range",
"start_block_id": "blk_start",
"end_block_id": "blk_end",
"context_before": "2",
"context_after": "1",
"max_depth": "0",
},
},
{
name: "keyword with query",
setFlags: map[string]string{
"scope": "keyword",
"keyword": "foo|bar",
"context-before": "1",
},
want: map[string]interface{}{
"read_mode": "keyword",
"keyword": "foo|bar",
"context_before": "1",
},
},
{
name: "section keeps unlimited depth omitted",
setFlags: map[string]string{
"scope": "section",
"start-block-id": "blk_heading",
"max-depth": "-1",
},
want: map[string]interface{}{
"read_mode": "section",
"start_block_id": "blk_heading",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
if got := buildReadOption(runtime); !reflect.DeepEqual(got, tt.want) {
t.Fatalf("buildReadOption() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestValidateReadModeFlagsRejectsInvalidScopeOptions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
wantParam string
wantParams []string
}{
{
name: "negative context before",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"context-before": "-1",
},
wantParam: "--context-before",
},
{
name: "negative context after",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"context-after": "-1",
},
wantParam: "--context-after",
},
{
name: "max depth below unlimited sentinel",
setFlags: map[string]string{
"scope": "range",
"start-block-id": "blk_start",
"max-depth": "-2",
},
wantParam: "--max-depth",
},
{
name: "range needs boundary",
setFlags: map[string]string{
"scope": "range",
},
wantParams: []string{
"--start-block-id",
"--end-block-id",
},
},
{
name: "keyword needs keyword",
setFlags: map[string]string{
"scope": "keyword",
},
wantParam: "--keyword",
},
{
name: "section needs start block",
setFlags: map[string]string{
"scope": "section",
},
wantParam: "--start-block-id",
},
{
name: "unknown scope",
setFlags: map[string]string{
"scope": "bad",
},
wantParam: "--scope",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
err := validateReadModeFlags(runtime)
if err == nil {
t.Fatal("validateReadModeFlags() succeeded, want error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam, tt.wantParams...)
})
}
}
func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
}{
{
name: "outline",
setFlags: map[string]string{
"scope": "outline",
},
},
{
name: "range with end block",
setFlags: map[string]string{
"scope": "range",
"end-block-id": "blk_end",
},
},
{
name: "keyword with keyword",
setFlags: map[string]string{
"scope": "keyword",
"keyword": "bug|缺陷",
},
},
{
name: "section with start block",
setFlags: map[string]string{
"scope": "section",
"start-block-id": "blk_heading",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
if err := validateReadModeFlags(runtime); err != nil {
t.Fatalf("validateReadModeFlags() error = %v", err)
}
})
}
}
func TestValidateFetchV2RejectsInvalidDocAndScope(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
wantParam string
}{
{
name: "invalid doc",
setFlags: map[string]string{
"doc": "https://example.com/sheets/sht_token",
},
wantParam: "--doc",
},
{
name: "invalid scope",
setFlags: map[string]string{
"scope": "bad",
},
wantParam: "--scope",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
err := validateFetchV2(context.Background(), runtime)
if err == nil {
t.Fatal("validateFetchV2() succeeded, want error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, tt.wantParam)
})
}
}
func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) {
t.Parallel()
tests := []struct {
name string
setFlags map[string]string
}{
{
name: "xml format",
setFlags: map[string]string{
"doc-format": "xml",
"detail": "full",
},
},
{
name: "markdown simple detail",
setFlags: map[string]string{
"doc-format": "markdown",
"detail": "simple",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchBodyTestRuntime(context.Background())
for name, value := range tt.setFlags {
mustSetFetchFlag(t, runtime, name, value)
}
data := map[string]interface{}{}
if got := addFetchDetailDowngradeWarning(runtime, data); got != "" {
t.Fatalf("warning = %q, want empty", got)
}
if _, ok := data["warnings"]; ok {
t.Fatalf("unexpected warnings: %#v", data["warnings"])
}
})
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
@@ -141,36 +507,54 @@ func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
}
}
func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": "im-markdown",
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
if got, want := dry.API[0].Body["format"], "markdown"; got != want {
t.Fatalf("dry-run format = %#v, want %q", got, want)
}
}
func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) {
t.Parallel()
for _, detail := range []string{"with-ids", "full"} {
t.Run(detail, func(t *testing.T) {
t.Parallel()
for _, format := range []string{"markdown", "im-markdown"} {
for _, detail := range []string{"with-ids", "full"} {
t.Run(format+"/"+detail, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": "markdown",
"detail": detail,
runtime := newFetchShortcutTestRuntime(t, "", map[string]string{
"doc-format": format,
"detail": detail,
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
if exportOption == nil {
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
}
if got := exportOption["export_block_id"]; got != false {
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_style_attrs"]; got != false {
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_cite_extra_data"]; got != false {
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
}
})
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
exportOption, _ := dry.API[0].Body["export_option"].(map[string]interface{})
if exportOption == nil {
t.Fatalf("missing export_option: %#v", dry.API[0].Body)
}
if got := exportOption["export_block_id"]; got != false {
t.Fatalf("export_block_id = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_style_attrs"]; got != false {
t.Fatalf("export_style_attrs = %#v, want false after markdown detail downgrade", got)
}
if got := exportOption["export_cite_extra_data"]; got != false {
t.Fatalf("export_cite_extra_data = %#v, want false after markdown detail downgrade", got)
}
})
}
}
}
@@ -261,6 +645,107 @@ func TestDocsFetchMarkdownDetailDowngradeWarnsInPrettyOutput(t *testing.T) {
}
}
func TestDocsFetchV2ReturnsAPIError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-api-error"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchAPIError/fetch",
Body: map[string]interface{}{
"code": 999999,
"msg": "fetch failed",
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchAPIError",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("mountAndRunDocs() succeeded, want API error")
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *errs.APIError (%v)", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category = %q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeUnknown {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
}
if p.Code != 999999 {
t.Errorf("code = %d, want 999999", p.Code)
}
if p.Message != "fetch failed" {
t.Errorf("message = %q, want %q", p.Message, "fetch failed")
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected wrapped cause for API response error: %T %v", cause, cause)
}
}
func TestDocsFetchIMMarkdownConvertsContentInJSONOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdown/fetch",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcnFetchIMMarkdown",
"revision_id": float64(1),
"content": strings.Join([]string{
`<title>Doc Title</title>`,
`<callout emoji="💡">Read **this**.</callout>`,
`<bookmark name="Example" href="https://example.com"></bookmark>`,
}, "\n\n"),
},
},
},
})
err := mountAndRunDocs(t, DocsFetch, []string{
"+fetch",
"--doc", "doxcnFetchIMMarkdown",
"--doc-format", "im-markdown",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
doc, _ := data["document"].(map[string]interface{})
content, _ := doc["content"].(string)
for _, want := range []string{
"# Doc Title",
"---\n💡 Read **this**.\n---",
"[Example](https://example.com)",
} {
if !strings.Contains(content, want) {
t.Fatalf("converted content missing %q:\n%s", want, content)
}
}
if strings.Contains(content, "<title>") || strings.Contains(content, "<callout") || strings.Contains(content, "<bookmark") {
t.Fatalf("converted content still contains downgraded XML tags:\n%s", content)
}
}
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
@@ -291,6 +776,7 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
if err == nil {
t.Fatal("expected v2-only validation error")
}
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--offset")
for _, want := range tt.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
@@ -316,6 +802,14 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) {
t.Helper()
if err := runtime.Cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()

View File

@@ -74,6 +74,9 @@ var DocsSearch = common.Shortcut{
"page_token": data["page_token"],
"results": normalizedItems,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
if len(normalizedItems) == 0 {

View File

@@ -7,8 +7,48 @@ import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"res_units": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("DocsSearch.Execute() error = %v", err)
}
reg.Verify(t)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
}
data, _ := env["data"].(map[string]interface{})
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
t.Parallel()

View File

@@ -121,7 +121,7 @@ const (
var DriveAddComment = common.Shortcut{
Service: "drive",
Command: "+add-comment",
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
Risk: "write",
Scopes: []string{
"drive:drive.metadata:readonly",
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
@@ -148,6 +148,17 @@ var DriveAddComment = common.Shortcut{
return err
}
if docRef.Kind == "base" {
if runtime.Bool("full-comment") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
}
_, err := parseBaseCommentAnchor(runtime)
return err
}
// Sheet comment validation.
if docRef.Kind == "sheet" {
blockID := strings.TrimSpace(runtime.Str("block-id"))
@@ -188,7 +199,7 @@ var DriveAddComment = common.Shortcut{
return validateFileCommentMode(mode, "")
}
if mode == commentModeLocal && docRef.Kind == "doc" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
}
return nil
@@ -215,6 +226,23 @@ var DriveAddComment = common.Shortcut{
resolvedToken = target.FileToken
}
if resolvedKind == "base" {
anchor, err := parseBaseCommentAnchor(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
desc := "1-step request: create base(bitable) record-local comment"
if isWiki {
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
}
return common.NewDryRunAPI().
Desc(desc).
POST("/open-apis/drive/v1/files/:file_token/new_comments").
Body(commentBody).
Set("file_token", resolvedToken)
}
// Sheet comment dry-run.
if resolvedKind == "sheet" {
anchor, _ := parseSheetCellRef(blockID)
@@ -352,6 +380,14 @@ var DriveAddComment = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// Sheet comment: direct URL or token fast path.
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
if docRef.Kind == "base" {
return executeBaseComment(runtime, resolvedCommentTarget{
DocID: docRef.Token,
FileToken: docRef.Token,
FileType: "base",
ResolvedBy: "base",
})
}
if docRef.Kind == "sheet" {
return executeSheetComment(runtime, docRef)
}
@@ -375,6 +411,9 @@ var DriveAddComment = common.Shortcut{
if target.FileType == "slides" {
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
}
if target.FileType == "base" {
return executeBaseComment(runtime, target)
}
if target.FileType == "file" {
return executeFileComment(runtime, target)
}
@@ -482,6 +521,12 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/base/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/bitable/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
@@ -495,7 +540,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{Kind: "doc", Token: token}, nil
}
if strings.Contains(raw, "://") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
}
if strings.ContainsAny(raw, "/?#") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
@@ -504,7 +549,10 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
// Bare token: --type is required.
docType = strings.TrimSpace(docType)
if docType == "" {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
}
if docType == "bitable" || docType == "base" {
return commentDocRef{Kind: "base", Token: raw}, nil
}
return commentDocRef{Kind: docType, Token: raw}, nil
}
@@ -515,11 +563,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
return resolvedCommentTarget{}, err
}
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
if mode == commentModeLocal {
switch docRef.Kind {
case "doc":
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
case "file":
if err := validateFileCommentMode(mode, ""); err != nil {
return resolvedCommentTarget{}, err
@@ -557,6 +605,22 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
}
if objType == "bitable" || objType == "base" {
if runtime.Bool("full-comment") {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
}
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
return resolvedCommentTarget{
DocID: objToken,
FileToken: objToken,
FileType: "base",
ResolvedBy: "wiki",
WikiToken: docRef.Token,
}, nil
}
if objType == "sheet" {
// Sheet comments are handled via the sheet fast path in Execute.
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -592,10 +656,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}, nil
}
if mode == commentModeLocal && objType != "docx" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
}
if mode == commentModeFull && objType != "docx" && objType != "doc" {
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
@@ -787,6 +851,12 @@ type sheetAnchor struct {
Row int
}
type baseAnchor struct {
BlockID string
BaseRecordID string
BaseViewID string
}
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
body := map[string]interface{}{
"file_type": fileType,
@@ -813,6 +883,18 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
return body
}
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
return map[string]interface{}{
"file_type": "bitable",
"reply_elements": replyElements,
"anchor": map[string]interface{}{
"block_id": anchor.BlockID,
"base_record_id": anchor.BaseRecordID,
"base_view_id": anchor.BaseViewID,
},
}
}
func anchorBlockIDForDryRun(blockID string) string {
if strings.TrimSpace(blockID) != "" {
return strings.TrimSpace(blockID)
@@ -820,6 +902,26 @@ func anchorBlockIDForDryRun(blockID string) string {
return "<anchor_block_id>"
}
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
blockID := strings.TrimSpace(runtime.Str("block-id"))
if blockID == "" {
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
}
return parseBaseBlockRef(blockID)
}
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
parts := strings.Split(strings.TrimSpace(blockID), "!")
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
}
return baseAnchor{
BlockID: strings.TrimSpace(parts[0]),
BaseRecordID: strings.TrimSpace(parts[1]),
BaseViewID: strings.TrimSpace(parts[2]),
}, nil
}
func parseSlidesBlockRef(blockID string) (string, string, error) {
blockID = strings.TrimSpace(blockID)
if blockID == "" {
@@ -1030,6 +1132,53 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
return nil
}
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {
return err
}
anchor, err := parseBaseCommentAnchor(runtime)
if err != nil {
return err
}
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
if err != nil {
return err
}
out := map[string]interface{}{
"file_token": target.FileToken,
"file_type": "bitable",
"resolved_by": target.ResolvedBy,
"comment_mode": "base_record",
"base_block_id": anchor.BlockID,
"base_record_id": anchor.BaseRecordID,
"base_view_id": anchor.BaseViewID,
}
if commentID := data["comment_id"]; commentID != nil {
out["comment_id"] = commentID
}
if replyID := data["reply_id"]; replyID != nil {
out["reply_id"] = replyID
}
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
out["created_at"] = createdAt
}
if target.WikiToken != "" {
out["wiki_token"] = target.WikiToken
}
runtime.Out(out, nil)
return nil
}
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
if err != nil {

View File

@@ -133,6 +133,20 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "file",
wantToken: "fileToken",
},
{
name: "raw token with type bitable",
input: "baseToken",
docType: "bitable",
wantKind: "base",
wantToken: "baseToken",
},
{
name: "raw token with type base alias",
input: "baseToken",
docType: "base",
wantKind: "base",
wantToken: "baseToken",
},
{
name: "raw token without type",
input: "xxxxxx",
@@ -156,6 +170,18 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "file",
wantToken: "boxcn123",
},
{
name: "base url",
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
wantKind: "base",
wantToken: "baseToken123",
},
{
name: "bitable url",
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
wantKind: "base",
wantToken: "baseToken456",
},
{
name: "unsupported url",
input: "https://example.com/not-a-doc",
@@ -726,6 +752,35 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
}
}
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
t.Parallel()
replyElements := []map[string]interface{}{
{"type": "text", "text": "base comment"},
}
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
BlockID: "tbl9mp6fj9kDKHQV",
BaseRecordID: "recBIBgGmb",
BaseViewID: "vewc46MG1R",
})
if got["file_type"] != "bitable" {
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
}
anchor, ok := got["anchor"].(map[string]interface{})
if !ok {
t.Fatalf("expected anchor map, got %#v", got["anchor"])
}
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
}
if anchor["base_record_id"] != "recBIBgGmb" {
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
}
if anchor["base_view_id"] != "vewc46MG1R" {
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
}
}
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
func TestParseSheetCellRef(t *testing.T) {
@@ -985,6 +1040,78 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
}
}
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
t.Fatalf("expected block-id required error, got: %v", err)
}
}
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
cases := []string{
"tbl9mp6fj9kDKHQV",
"tbl9mp6fj9kDKHQV!recBIBgGmb",
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
}
for _, blockID := range cases {
t.Run(blockID, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", blockID,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
t.Fatalf("expected block-id format error, got: %v", err)
}
})
}
}
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
}{
{
name: "full comment",
args: []string{"--full-comment"},
wantErr: "--full-comment is not applicable for base(bitable) comments",
},
{
name: "selection",
args: []string{"--selection-with-ellipsis", "some text"},
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
args := []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}
args = append(args, tc.args...)
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
}
})
}
}
// ── Slides comment execute tests ────────────────────────────────────────────
func TestSlidesCommentExecuteSuccess(t *testing.T) {
@@ -1195,6 +1322,87 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
}
}
func TestBaseCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"comment_id": "baseComment123",
"reply_id": "baseReply123",
"created_at": 1700000000,
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"请看这条记录"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var requestBody map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
}
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
t.Fatalf("request file_type = %q, want bitable", got)
}
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
}
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
}
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
}
}
func TestBaseCommentExecuteBareToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "baseBareComment"},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "baseBareToken",
"--type", "bitable",
"--content", `[{"type":"text","text":"ok"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "baseBareComment") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
}
func TestFileCommentExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1433,6 +1641,40 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
}
}
func TestDryRunBaseDirectURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/base/baseToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "record-local comment") {
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
api := mustSliceValue(t, out["api"], "api")
call := mustMapValue(t, api[0], "api[0]")
body := mustMapValue(t, call["body"], "api[0].body")
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
}
}
func TestDryRunWikiResolvesToSlides(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1636,25 +1878,92 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
}
}
func TestResolveWikiToUnsupportedType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
},
func TestResolveWikiToBaseComment(t *testing.T) {
for _, objType := range []string{"bitable", "base"} {
t.Run(objType, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "wikiBaseComment") {
t.Fatalf("stdout missing comment_id: %s", stdout.String())
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
}
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
}
})
}
}
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
cases := []struct {
name string
args []string
wantErr string
}{
{
name: "full comment",
args: []string{"--full-comment"},
wantErr: "--full-comment is not applicable for base(bitable) comments",
},
})
err := mountAndRunDrive(t, DriveAddComment, []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
t.Fatalf("expected unsupported type error, got: %v", err)
{
name: "selection",
args: []string{"--selection-with-ellipsis", "some text"},
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
},
},
})
args := []string{
"+add-comment",
"--doc", "https://example.larksuite.com/wiki/wikiToken",
"--content", `[{"type":"text","text":"test"}]`,
"--as", "user",
}
args = append(args, tc.args...)
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
}
})
}
}
@@ -1735,7 +2044,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
"--block-id", "blk_123",
"--as", "user",
}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
}
}

View File

@@ -5,6 +5,7 @@ package drive
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@@ -15,6 +16,24 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
// wrapExportContextErr converts a context cancellation / deadline error into a
// typed errs.NetworkError so the cobra layer sees a typed envelope (with cause
// preserved for errors.Is) instead of an untyped context.Canceled /
// context.DeadlineExceeded escaping as a plain string. CR-flagged hole on the
// poll loop: returning ctx.Err() directly bypassed the typed-error contract.
func wrapExportContextErr(err error) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
msg := "drive +export polling cancelled: %s"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
msg = "drive +export polling deadline exceeded: %s"
}
return errs.NewNetworkError(subtype, msg, err).WithCause(err)
}
// DriveExport exports Drive-native documents to local files and falls back to
// a follow-up command when the async export task does not finish in time.
var DriveExport = common.Shortcut{
@@ -40,236 +59,305 @@ var DriveExport = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveExportSpec(driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
})
return validateExport(exportParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
return PlanExportDryRun(runtime, exportParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return RunExport(ctx, runtime, exportParamsFromFlags(runtime))
},
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
// ExportParams holds the user-facing inputs for an export flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-export) can reuse
// the drive export implementation. An empty OutputDir means "create the export
// task and poll, but do not download" — callers that only need the ready file
// token / status get it back without writing a local file.
type ExportParams struct {
Token string
DocType string
FileExtension string
SubID string
OnlySchema bool
OutputDir string
FileName string
Overwrite bool
}
func (p ExportParams) spec() driveExportSpec {
return driveExportSpec{
Token: p.Token,
DocType: p.DocType,
FileExtension: p.FileExtension,
SubID: p.SubID,
OnlySchema: p.OnlySchema,
}
}
// exportParamsFromFlags reads the standard drive +export flag set.
func exportParamsFromFlags(runtime *common.RuntimeContext) ExportParams {
// drive +export always downloads; an empty --output-dir historically means
// the current directory (saveContentToOutputDir maps "" -> "."), so normalize
// it here to keep behavior identical and stay off the export-only ("" => skip
// download) path that only sheets +workbook-export uses.
outputDir := runtime.Str("output-dir")
if outputDir == "" {
outputDir = "."
}
return ExportParams{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
OutputDir: outputDir,
FileName: strings.TrimSpace(runtime.Str("file-name")),
Overwrite: runtime.Bool("overwrite"),
}
}
// validateExport runs the CLI-level export constraint checks. Unexported because
// only drive +export's Validate consumes it directly; sheets +workbook-export
// reuses RunExport / PlanExportDryRun but inlines its own (sheet-specific)
// validation, so there is no cross-package call site to keep exported.
func validateExport(p ExportParams) error {
return validateDriveExportSpec(p.spec())
}
// PlanExportDryRun builds the dry-run plan for an export without performing I/O.
func PlanExportDryRun(runtime *common.RuntimeContext, p ExportParams) *common.DryRunAPI {
spec := p.spec()
// Markdown export is a special case: docx markdown comes from the V2
// docs_ai fetch API directly instead of the Drive export task API.
if spec.FileExtension == "markdown" {
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", runtime.Str("output-dir"))
if name := strings.TrimSpace(runtime.Str("file-name")); name != "" {
Desc("2-step orchestration: fetch docx markdown -> write local file").
POST(apiPath).
Body(map[string]interface{}{
"format": "markdown",
}).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveExportSpec{
Token: runtime.Str("token"),
DocType: runtime.Str("doc-type"),
FileExtension: runtime.Str("file-extension"),
SubID: runtime.Str("sub-id"),
OnlySchema: runtime.Bool("only-schema"),
}
body := map[string]interface{}{
"token": spec.Token,
"type": spec.DocType,
"file_extension": spec.FileExtension,
}
if strings.TrimSpace(spec.SubID) != "" {
body["sub_id"] = spec.SubID
}
if spec.OnlySchema {
body["only_schema"] = true
}
dr := common.NewDryRunAPI().
Desc("3-step orchestration: create export task -> limited polling -> download file").
POST("/open-apis/drive/v1/export_tasks").
Body(body).
Set("output_dir", p.OutputDir)
if name := strings.TrimSpace(p.FileName); name != "" {
dr.Set("file_name", ensureExportFileExtension(sanitizeExportFileName(name, spec.Token), spec.FileExtension))
}
return dr
}
// RunExport drives create export task -> bounded poll -> optional download. It
// is the shared core behind both drive +export and sheets +workbook-export. An
// empty p.OutputDir skips the download step and returns the ready file token.
func RunExport(ctx context.Context, runtime *common.RuntimeContext, p ExportParams) error {
spec := p.spec()
outputDir := p.OutputDir
preferredFileName := strings.TrimSpace(p.FileName)
overwrite := p.Overwrite
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
if err != nil {
return err
}
outputDir := runtime.Str("output-dir")
preferredFileName := strings.TrimSpace(runtime.Str("file-name"))
overwrite := runtime.Bool("overwrite")
// Markdown export bypasses the async export task and writes the fetched
// markdown content directly to disk. Uses the V2 docs_ai fetch API for
// higher-quality Lark-flavored Markdown output.
if spec.FileExtension == "markdown" {
fmt.Fprintf(runtime.IO().ErrOut, "Exporting docx as markdown: %s\n", common.MaskToken(spec.Token))
apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", validate.EncodePathSegment(spec.Token))
data, err := runtime.CallAPITyped(
"POST",
apiPath,
nil,
map[string]interface{}{
"format": "markdown",
},
)
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
return err
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
if err != nil {
return err
}
// Extract content from the V2 response: data.document.content
doc, ok := data["document"].(map[string]interface{})
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document object")
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return wrapExportContextErr(ctx.Err())
case <-time.After(driveExportPollInterval):
}
content, ok := doc["content"].(string)
if !ok {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "invalid markdown fetch response: missing document.content")
}
if err := ctx.Err(); err != nil {
return wrapExportContextErr(err)
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
// Export-only mode: caller wants the ready file token / metadata but
// no local download (e.g. sheets +workbook-export without an output
// path). Skip the download and return the status envelope.
if strings.TrimSpace(outputDir) == "" {
runtime.Out(map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_token": status.FileToken,
"file_name": status.FileName,
"file_size": status.FileSize,
"ready": true,
"downloaded": false,
}, nil)
return nil
}
fileName := preferredFileName
if fileName == "" {
// Prefer the remote title for the exported file name, but still fall
// back to the token if metadata is empty.
title, err := common.FetchDriveMetaTitle(runtime, spec.Token, spec.DocType)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "Title lookup failed, using token as filename: %v\n", err)
title = spec.Token
}
fileName = title
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
savedPath, err := saveContentToOutputDir(runtime.FileIO(), outputDir, fileName, []byte(content), overwrite)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
return err
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
runtime.Out(map[string]interface{}{
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"file_name": filepath.Base(savedPath),
"saved_path": savedPath,
"size_bytes": len(content),
}, nil)
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
ticket, err := createDriveExportTask(runtime, spec)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Created export task: %s\n", ticket)
var lastStatus driveExportStatus
var lastPollErr error
hasObservedStatus := false
// Keep the command responsive by polling for a bounded window. If the task
// is still running after that, return a resume command instead of blocking.
for attempt := 1; attempt <= driveExportPollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(driveExportPollInterval):
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
if err := ctx.Err(); err != nil {
return err
}
status, err := getDriveExportStatus(runtime, spec.Token, ticket)
if err != nil {
// Treat polling failures as transient so short-lived backend hiccups
// do not immediately fail an otherwise healthy export task.
lastPollErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Export status attempt %d/%d failed: %v\n", attempt, driveExportPollAttempts, err)
continue
}
lastStatus = status
hasObservedStatus = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Export task completed: %s\n", common.MaskToken(status.FileToken))
fileName := preferredFileName
if fileName == "" {
fileName = status.FileName
}
fileName = ensureExportFileExtension(sanitizeExportFileName(fileName, spec.Token), spec.FileExtension)
out, err := downloadDriveExportFile(ctx, runtime, status.FileToken, outputDir, fileName, overwrite)
if err != nil {
recoveryCommand := driveExportDownloadCommand(status.FileToken, fileName, outputDir, overwrite)
hint := fmt.Sprintf(
"the export artifact is already ready (ticket=%s, file_token=%s)\nretry download with: %s",
ticket,
status.FileToken,
recoveryCommand,
)
return appendDriveExportRecoveryHint(err, hint)
}
out["ticket"] = ticket
out["doc_type"] = spec.DocType
out["file_extension"] = spec.FileExtension
runtime.Out(out, nil)
return nil
}
if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
return errs.NewAPIError(errs.SubtypeServerError, "export task failed: %s (ticket=%s)", msg, ticket)
}
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
fmt.Fprintf(runtime.IO().ErrOut, "Export status %d/%d: %s\n", attempt, driveExportPollAttempts, status.StatusLabel())
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
},
nextCommand := driveExportTaskResultCommand(ticket, spec.Token)
if !hasObservedStatus && lastPollErr != nil {
hint := fmt.Sprintf(
"the export task was created but every status poll failed (ticket=%s)\nretry status lookup with: %s",
ticket,
nextCommand,
)
return appendDriveExportRecoveryHint(lastPollErr, hint)
}
failed := false
var jobStatus interface{}
jobStatusLabel := "unknown"
if hasObservedStatus {
failed = lastStatus.Failed()
jobStatus = lastStatus.JobStatus
jobStatusLabel = lastStatus.StatusLabel()
}
// Return the last observed status so callers can resume from a known task
// state instead of losing all progress information on timeout.
result := map[string]interface{}{
"ticket": ticket,
"token": spec.Token,
"doc_type": spec.DocType,
"file_extension": spec.FileExtension,
"ready": false,
"failed": failed,
"job_status": jobStatus,
"job_status_label": jobStatusLabel,
"timed_out": true,
"next_command": nextCommand,
}
if preferredFileName != "" {
result["file_name"] = ensureExportFileExtension(sanitizeExportFileName(preferredFileName, spec.Token), spec.FileExtension)
}
runtime.Out(result, nil)
fmt.Fprintf(runtime.IO().ErrOut, "Export task is still in progress. Continue with: %s\n", nextCommand)
return nil
}

View File

@@ -5,6 +5,7 @@ package drive
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
@@ -497,6 +498,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
@@ -1034,3 +1101,37 @@ func TestDriveTaskResultExportIncludesReadyFlags(t *testing.T) {
t.Fatalf("stdout missing job_status_label: %s", stdout.String())
}
}
// TestWrapExportContextErr verifies the export poll loop's typed wrapping for
// context cancellation / deadline. Previously the poll loop returned ctx.Err()
// directly so an untyped context.Canceled would escape as a plain string at
// the command layer, bypassing the typed-error contract.
func TestWrapExportContextErr(t *testing.T) {
if err := wrapExportContextErr(nil); err != nil {
t.Errorf("wrapExportContextErr(nil) = %v, want nil", err)
}
cancelled := wrapExportContextErr(context.Canceled)
var netErrCancel *errs.NetworkError
if !errors.As(cancelled, &netErrCancel) {
t.Fatalf("wrapExportContextErr(Canceled) = %T, want *errs.NetworkError", cancelled)
}
if netErrCancel.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Canceled subtype = %q, want %q", netErrCancel.Subtype, errs.SubtypeNetworkTransport)
}
if !errors.Is(cancelled, context.Canceled) {
t.Error("wrapExportContextErr should preserve context.Canceled via errors.Is")
}
deadline := wrapExportContextErr(context.DeadlineExceeded)
var netErrDeadline *errs.NetworkError
if !errors.As(deadline, &netErrDeadline) {
t.Fatalf("wrapExportContextErr(DeadlineExceeded) = %T, want *errs.NetworkError", deadline)
}
if netErrDeadline.Subtype != errs.SubtypeNetworkTimeout {
t.Errorf("DeadlineExceeded subtype = %q, want %q", netErrDeadline.Subtype, errs.SubtypeNetworkTimeout)
}
if !errors.Is(deadline, context.DeadlineExceeded) {
t.Error("wrapExportContextErr should preserve context.DeadlineExceeded via errors.Is")
}
}

View File

@@ -35,132 +35,164 @@ var DriveImport = common.Shortcut{
{Name: "target-token", Desc: "existing token to import data into (only for type=bitable); when set, data is mounted into this bitable instead of creating a new one"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveImportSpec(driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
})
return ValidateImport(importParamsFromFlags(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
return PlanImportDryRun(runtime, importParamsFromFlags(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveImportSpec{
FilePath: runtime.Str("file"),
DocType: strings.ToLower(runtime.Str("type")),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
return RunImport(ctx, runtime, importParamsFromFlags(runtime))
},
}
// ImportParams holds the user-facing inputs for an import flow, decoupled from
// cobra flags so other command groups (e.g. sheets +workbook-import) can reuse
// the drive import implementation without taking a dependency on a --type flag.
type ImportParams struct {
File string
DocType string
FolderToken string
Name string
TargetToken string
}
func (p ImportParams) spec() driveImportSpec {
return driveImportSpec{
FilePath: p.File,
DocType: strings.ToLower(p.DocType),
FolderToken: p.FolderToken,
Name: p.Name,
TargetToken: p.TargetToken,
}
}
// importParamsFromFlags reads the standard drive +import flag set.
func importParamsFromFlags(runtime *common.RuntimeContext) ImportParams {
return ImportParams{
File: runtime.Str("file"),
DocType: runtime.Str("type"),
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
TargetToken: runtime.Str("target-token"),
}
}
// ValidateImport runs the CLI-level compatibility checks for an import.
func ValidateImport(p ImportParams) error {
return validateDriveImportSpec(p.spec())
}
// PlanImportDryRun builds the dry-run plan (upload -> create task -> poll) for
// an import without performing any network or file I/O beyond a local stat.
func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.DryRunAPI {
spec := p.spec()
fileSize, err := preflightDriveImportFile(runtime.FileIO(), &spec)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if valErr := validateDriveImportSpec(spec); valErr != nil {
return common.NewDryRunAPI().Set("error", valErr.Error())
}
dry := common.NewDryRunAPI()
dry.Desc("Upload file (single-part or multipart) -> create import task -> poll status")
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
Body(spec.CreateTaskBody("<file_token>"))
dry.GET("/open-apis/drive/v1/import_tasks/:ticket").
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry
}
// RunImport executes the full import flow: upload media -> create import task ->
// bounded poll, then writes the result envelope to the runtime output. It is
// the shared core behind both drive +import and sheets +workbook-import.
func RunImport(ctx context.Context, runtime *common.RuntimeContext, p ImportParams) error {
spec := p.spec()
if _, err := preflightDriveImportFile(runtime.FileIO(), &spec); err != nil {
return err
}
if err := rejectDriveImportWikiFolderToken(runtime, spec.FolderToken); err != nil {
return err
}
// Step 1: Upload file as media
fileToken, uploadErr := uploadMediaForImport(ctx, runtime, spec.FilePath, spec.SourceFileName(), spec.DocType)
if uploadErr != nil {
return uploadErr
}
fmt.Fprintf(runtime.IO().ErrOut, "Creating import task for %s as %s...\n", spec.TargetFileName(), spec.DocType)
// Step 2: Create import task
ticket, err := createDriveImportTask(runtime, spec, fileToken)
if err != nil {
return err
}
// Step 3: Poll task
fmt.Fprintf(runtime.IO().ErrOut, "Polling import task %s...\n", ticket)
status, ready, err := pollDriveImportTask(runtime, ticket)
if err != nil {
return err
}
// Some intermediate responses omit the final type, so fall back to the
// requested type to keep the output shape stable.
resultType := status.DocType
if resultType == "" {
resultType = spec.DocType
}
out := map[string]interface{}{
"ticket": ticket,
"type": resultType,
"ready": ready,
"job_status": status.JobStatus,
"job_status_label": status.StatusLabel(),
}
if status.Token != "" {
out["token"] = status.Token
}
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
out["url"] = statusURL
} else if status.Token != "" {
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
out["url"] = u
}
}
if status.JobErrorMsg != "" {
out["job_error_msg"] = status.JobErrorMsg
}
if status.Extra != nil {
out["extra"] = status.Extra
}
if !ready {
nextCommand := driveImportTaskResultCommand(ticket)
fmt.Fprintf(runtime.IO().ErrOut, "Import task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
if ready {
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, common.GetString(out, "token"), resultType); grant != nil {
out["permission_grant"] = grant
}
}
runtime.Out(out, nil)
return nil
}
func preflightDriveImportFile(fio fileio.FileIO, spec *driveImportSpec) (int64, error) {
// Keep dry-run and execution aligned on path normalization, file existence,
// and format-specific size limits before planning the upload path.

View File

@@ -149,6 +149,9 @@ var DriveSearch = common.Shortcut{
"page_token": data["page_token"],
"results": normalizedItems,
}
if notice, _ := data["notice"].(string); notice != "" {
resultData["notice"] = notice
}
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
renderDriveSearchTable(w, data, normalizedItems)

View File

@@ -14,12 +14,49 @@ import (
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
// narrows --opened-since / --opened-until and generates the multi-slice notice.
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/search/v2/doc_wiki/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"res_units": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("DriveSearch.Execute() error = %v", err)
}
reg.Verify(t)
var env map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
}
data, _ := env["data"].(map[string]interface{})
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
func TestClampOpenedTimeWindow(t *testing.T) {
t.Parallel()

View File

@@ -26,9 +26,7 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
return string(b)
}
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -59,9 +57,38 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
for name := range stringFlags {
if name == "page-size" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
@@ -231,6 +258,7 @@ func TestIsMediaKey(t *testing.T) {
}
}
// TestShortcutValidateBranches covers direct shortcut validation branches.
func TestShortcutValidateBranches(t *testing.T) {
t.Run("ImChatCreate valid", func(t *testing.T) {
@@ -297,7 +325,7 @@ func TestShortcutValidateBranches(t *testing.T) {
})
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": "ok",
"page-size": "0",
}, nil)
@@ -307,12 +335,13 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatSearch query too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 65),
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 81),
"page-size": "20",
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
if err != nil {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
@@ -440,6 +469,29 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesSend audio rejects non-opus local file", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"audio": "./voice.mp3",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend audio accepts opus and ogg local files", func(t *testing.T) {
for _, audio := range []string{"./voice.opus", "./voice.ogg"} {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"audio": audio,
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate(%q) unexpected error = %v", audio, err)
}
}
})
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
@@ -463,6 +515,17 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImMessagesReply audio rejects non-opus local file", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"audio": "./voice.mp3",
}, nil)
err := ImMessagesReply.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
}
})
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"thread": "bad_thread",
@@ -607,6 +670,7 @@ func TestShortcutValidateBranches(t *testing.T) {
})
}
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
func TestMessagesSearchPaginationConfig(t *testing.T) {
t.Run("default single page", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
@@ -650,8 +714,7 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
})
}
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
@@ -674,19 +737,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
})
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"page-size": "50",
"page-token": "next_page",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
t.Fatalf("ImChatSearch.DryRun() = %s", got)
}
})
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,

View File

@@ -1048,6 +1048,42 @@ func detectIMFileType(filePath string) string {
}
}
const (
audioMessageInputDesc = "audio file key (file_xxx), URL, or cwd-relative local path for a voice message (absolute paths and .. are rejected); local paths and URLs must be Opus (.opus or Ogg Opus .ogg). For mp3/wav, convert to .opus first, or use --file to send as an attachment"
audioMessageHint = "Convert non-Opus audio to .opus and use --audio for a voice message, for example: ffmpeg -i input.mp3 -acodec libopus -ac 1 -ar 16000 output.opus. To send the original mp3/wav as an attachment, use --file instead."
)
func validateAudioMessageInput(flagName, value string) error {
value = strings.TrimSpace(value)
if value == "" || isMediaKey(value) {
return nil
}
ext := audioInputExt(value)
if ext == "" {
return nil
}
if ext == ".opus" || ext == ".ogg" {
return nil
}
return errs.NewValidationError(
errs.SubtypeInvalidArgument,
"%s supports only Opus audio files for audio messages, such as .opus files or Ogg Opus (.ogg) files",
flagName,
).WithParam(flagName).WithHint("%s", audioMessageHint)
}
func audioInputExt(value string) string {
if isURL(value) {
parsed, err := url.Parse(value)
if err != nil {
return ""
}
return strings.ToLower(path.Ext(parsed.Path))
}
return strings.ToLower(filepath.Ext(value))
}
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files

View File

@@ -13,6 +13,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -50,6 +51,56 @@ func TestDetectIMFileType(t *testing.T) {
}
}
func TestValidateAudioMessageInput(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{name: "empty", value: ""},
{name: "existing file key", value: "file_abc"},
{name: "opus file", value: "./voice.opus"},
{name: "ogg opus file", value: "./voice.ogg"},
{name: "uppercase opus", value: "./VOICE.OPUS"},
{name: "mp3 local file", value: "./voice.mp3", wantErr: true},
{name: "wav local file", value: "./voice.wav", wantErr: true},
{name: "extensionless local path", value: "./voice"},
{name: "opus url", value: "https://example.com/voice.opus?download=1"},
{name: "ogg url", value: "https://example.com/voice.ogg?download=1"},
{name: "mp3 url", value: "https://example.com/voice.mp3?download=1", wantErr: true},
{name: "extensionless url", value: "https://example.com/download?id=1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAudioMessageInput("--audio", tt.value)
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
t.Fatalf("validateAudioMessageInput(%q) error = %v", tt.value, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("validateAudioMessageInput(%q) error is not typed: %v", tt.value, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("ProblemOf(%q) = category %q subtype %q", tt.value, p.Category, p.Subtype)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok || validationErr.Param != "--audio" {
t.Fatalf("validateAudioMessageInput(%q) param = %q, want --audio", tt.value, validationErr.Param)
}
if !strings.Contains(p.Hint, "use --file") || !strings.Contains(p.Hint, "ffmpeg") {
t.Fatalf("validateAudioMessageInput(%q) hint = %q, want --file and ffmpeg guidance", tt.value, p.Hint)
}
return
}
if err != nil {
t.Fatalf("validateAudioMessageInput(%q) unexpected error = %v", tt.value, err)
}
})
}
}
// TestSplitCSV covers the shared helper that replaced the three identical functions
func TestSplitCSV(t *testing.T) {
tests := []struct {

View File

@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (max 64 chars)"},
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
Params(params).
Body(body)
},
// Validate enforces query/member-ids presence, --query rune cap, search-types
// Validate enforces query/member-ids presence, search-types
// enum, --member-ids count and format, and --page-size bounds.
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
query := runtime.Str("query")
@@ -58,9 +58,6 @@ var ImChatSearch = common.Shortcut{
if query == "" && memberIDs == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
}
if query != "" && len([]rune(query)) > 64 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
}
if st := runtime.Str("search-types"); st != "" {
allowed := map[string]struct{}{
"private": {},
@@ -151,6 +148,9 @@ var ImChatSearch = common.Shortcut{
"has_more": hasMore,
"page_token": pageToken,
}
if notice, _ := resData["notice"].(string); notice != "" {
outData["notice"] = notice
}
if mfOut.Meta.Applied != "" {
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
}

View File

@@ -33,7 +33,7 @@ var ImMessagesReply = common.Shortcut{
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "audio", Desc: audioMessageInputDesc},
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
},
@@ -100,6 +100,9 @@ var ImMessagesReply = common.Shortcut{
return err
}
}
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
return err
}
if messageId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")

View File

@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
return err
}
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
if err != nil {
return err
}
@@ -103,6 +103,9 @@ var ImMessagesSearch = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "No matching messages found.")
})
@@ -131,6 +134,9 @@ var ImMessagesSearch = common.Shortcut{
"page_token": nextPageToken,
"note": "failed to fetch message details, returning ID list only",
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
for _, id := range messageIds {
@@ -206,6 +212,9 @@ var ImMessagesSearch = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No matching messages found.")
@@ -377,6 +386,7 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
}, nil
}
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
autoPaginate = runtime.Bool("page-all")
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
@@ -392,7 +402,8 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
return autoPaginate, pageLimit
}
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
// searchMessages fetches message search pages and returns the first server notice.
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
pageToken := ""
if tokens := req.params["page_token"]; len(tokens) > 0 {
@@ -410,6 +421,7 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
lastPageToken string
truncatedByLimit bool
pageCount int
notice string
)
for {
@@ -423,9 +435,12 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
if err != nil {
return nil, false, "", false, pageLimit, err
return nil, false, "", false, pageLimit, "", err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
items, _ := searchData["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
@@ -441,9 +456,10 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
pageToken = lastPageToken
}
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
}
// batchMGetMessages fetches message details in API-sized batches.
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
var items []interface{}
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
@@ -457,6 +473,7 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
return items, nil
}
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
chatContexts := map[string]map[string]interface{}{}
// Best-effort: a failed chunk only loses its own entries.
@@ -466,6 +483,7 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
return chatContexts
}
// chunkStrings splits a string slice into fixed-size batches.
func chunkStrings(items []string, chunkSize int) [][]string {
if len(items) == 0 || chunkSize <= 0 {
return nil

View File

@@ -37,7 +37,7 @@ var ImMessagesSend = common.Shortcut{
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
{Name: "audio", Desc: audioMessageInputDesc},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatFlag := runtime.Str("chat-id")
@@ -112,6 +112,9 @@ var ImMessagesSend = common.Shortcut{
return err
}
}
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
return err
}
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
return err

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
longQuery := strings.Repeat("q", 81)
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
var body map[string]interface{}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("decode request body: %w", err)
}
if got, _ := body["query"].(string); got != longQuery {
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
}), nil
}))
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
runtime.Format = "json"
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImChatSearch.Execute() error = %v", err)
}
data := decodeShortcutData(t, runtime)
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
runtime := newMessagesSearchRuntime(t, map[string]string{
"query": "incident",
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{},
"has_more": false,
"page_token": "",
},
}), nil
}))
runtime.Format = "json"
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
}
data := decodeShortcutData(t, runtime)
if got, _ := data["notice"].(string); got != notice {
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
}
}
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
cmd.Flags().String(name, "", "")
}
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
cmd.Flags().Bool(name, false, "")
}
cmd.Flags().Int("page-size", 20, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("query", query); err != nil {
t.Fatalf("Flags().Set(query) error = %v", err)
}
return cmd
}
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
t.Helper()
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if !ok {
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
}
var env map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
}
data, ok := env["data"].(map[string]interface{})
if !ok {
t.Fatalf("envelope data missing or wrong type: %#v", env)
}
return data
}

View File

@@ -159,6 +159,7 @@ var MailTriage = common.Shortcut{
var messages []map[string]interface{}
var hasMore bool
var nextPageToken string
var notice string
useSearch, err := resolveTriagePath(parsed, query, filter)
if err != nil {
@@ -189,6 +190,9 @@ var MailTriage = common.Shortcut{
if err != nil {
return err
}
if notice == "" {
notice, _ = searchData["notice"].(string)
}
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
messages = append(messages, pageMessages...)
pageHasMore, _ := searchData["has_more"].(bool)
@@ -282,8 +286,14 @@ var MailTriage = common.Shortcut{
"has_more": hasMore,
"page_token": nextPageToken,
}
if notice != "" {
outData["notice"] = notice
}
output.PrintJson(runtime.IO().Out, outData)
default: // "table"
if notice != "" {
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
}
if len(messages) == 0 {
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
return nil
@@ -760,13 +770,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["folder_id"] = folderIDFromFilter
}
} else {
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["folder_id"] = resolved
}
params["folder_id"] = folderIDFromFilter
}
} else if folderFromFilter != "" {
if dryRun {
@@ -776,13 +780,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["folder_id"] = folderFromFilter
}
} else {
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["folder_id"] = resolved
}
params["folder_id"] = folderFromFilter
}
}
@@ -801,13 +799,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["label_id"] = labelIDFromFilter
}
} else {
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["label_id"] = resolved
}
params["label_id"] = labelIDFromFilter
}
} else if labelFromFilter != "" {
if dryRun {
@@ -817,13 +809,7 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
params["label_id"] = labelFromFilter
}
} else {
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
if err != nil {
return nil, err
}
if resolved != "" {
params["label_id"] = resolved
}
params["label_id"] = labelFromFilter
}
}

View File

@@ -12,6 +12,7 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -974,7 +975,11 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Folder: "sent"}
got, err := buildListParams(rt, "me", f, 20, "", true)
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 20, "", true)
if err != nil {
t.Fatal(err)
}
@@ -983,10 +988,30 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
}
}
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Folder: "team-folder"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 20, "", true)
if err != nil {
t.Fatal(err)
}
if got["folder_id"] != "team-folder" {
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
}
}
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Label: "flagged"}
got, err := buildListParams(rt, "me", f, 10, "", true)
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 10, "", true)
if err != nil {
t.Fatal(err)
}
@@ -995,6 +1020,25 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
}
}
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
f := triageFilter{Label: "custom-label"}
resolved, err := resolveListFilter(rt, "me", f, true)
if err != nil {
t.Fatalf("resolveListFilter: %v", err)
}
got, err := buildListParams(rt, "me", resolved, 10, "", true)
if err != nil {
t.Fatal(err)
}
if _, ok := got["folder_id"]; ok {
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
}
if got["label_id"] != "custom-label" {
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
}
}
// --- buildSearchParams additional coverage ---
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
@@ -1478,14 +1522,16 @@ func boolPtr(v bool) *bool { return &v }
// --- mailbox_id preservation tests ---
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
tests := []struct {
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
name string
mailbox string
format string
args []string
register func(*httpmock.Registry, string)
wantCount int
wantNotice string
}{
{
name: "list json default mailbox",
@@ -1522,9 +1568,10 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
register: func(reg *httpmock.Registry, mailbox string) {
registerMailTriageSearchStub(reg, mailbox, []interface{}{
mailTriageSearchItem("search_pub_001", "Shared search"),
}, false, "")
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
},
wantCount: 1,
wantCount: 1,
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
},
{
name: "empty list json keeps top-level mailbox",
@@ -1559,6 +1606,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
if data["mailbox_id"] != tt.mailbox {
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
}
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
}
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != tt.wantCount {
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
@@ -1572,6 +1622,7 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
}
}
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
@@ -1604,6 +1655,7 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
}
}
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
tests := []struct {
name string
@@ -1654,6 +1706,33 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
}
}
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, stderr, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
registerMailTriageSearchStub(reg, "me", []interface{}{
mailTriageSearchItem("msg_search_notice", "Search notice result"),
}, false, "", notice)
if err := runMountedMailShortcut(t, MailTriage, []string{
"+triage",
"--query", strings.Repeat("q", 81),
}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
t.Fatalf("stdout should contain table row, got:\n%s", out)
}
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
}
}
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
t.Helper()
var data map[string]interface{}
@@ -1663,6 +1742,7 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
return data
}
// mailTriageMessagesFromOutput extracts triage messages as object maps.
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
t.Helper()
rawMessages, ok := data["messages"].([]interface{})
@@ -1715,7 +1795,8 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
})
}
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
@@ -1723,6 +1804,9 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
if pageToken != "" {
data["page_token"] = pageToken
}
if len(notices) > 0 && notices[0] != "" {
data["notice"] = notices[0]
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: mailboxPath(mailbox, "search"),
@@ -1751,3 +1835,137 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
},
}
}
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
// mailbox folders list API. Because it is non-reusable, any second hit returns
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
// use to prove resolveListFilter runs once and buildListParams does NOT
// re-resolve. folderID/folderName is the single custom folder the API reports.
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: mailboxPath(mailbox, "folders"),
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"id": folderID,
"name": folderName,
},
},
},
},
})
}
// registerMailTriageListPageStub registers one page of the messages list API,
// disambiguated from sibling pages by a URL substring unique to that page
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
// must NOT depend on query-param ordering: map iteration makes param order
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
// so reg.Verify catches under- or over-consumption.
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
data := map[string]interface{}{
"items": items,
"has_more": hasMore,
}
if pageToken != "" {
data["page_token"] = pageToken
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: urlSubstring,
Body: map[string]interface{}{
"code": 0,
"data": data,
},
})
}
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
// for the bug where buildListParams re-called resolveFolderID on every list
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
// easily tripping rate limits.
//
// Setup: a custom folder filter that forces resolveListFilter to hit the
// folders list API once (to map folder name "team-folder" to folder_id), then two
// messages-list pages. The folders list stub is non-reusable, so if
// buildListParams re-resolves, the second hit fails with "no stub". The
// messages-list stubs are page-specific (disambiguated by page_size in the
// URL), so both pages are served and Verify asserts each fired exactly once.
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
defer reg.Verify(t)
// listMailboxFolders (called once by resolveListFilter) gates on the
// mail:user_mailbox.folder:read scope, which the default test token does
// not carry. Re-store the token with that scope appended so the folders
// API call is actually exercised (and thus the non-reusable folders stub
// is the load-bearing "exactly once" assertion).
const folderScope = "mail:user_mailbox.folder:read"
cfg := mailTestConfig()
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
if !strings.Contains(stored.Scope, folderScope) {
stored.Scope = stored.Scope + " " + folderScope
if err := auth.SetStoredToken(stored); err != nil {
t.Fatalf("re-store token with folder scope: %v", err)
}
}
}
const (
mailbox = "me"
folderName = "team-folder"
folderID = "fld_custom_team"
page2Token = "tok_page2"
)
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
// on page 2. The page_size query value disambiguates the two list stubs.
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
page2IDs := []string{"msg_d", "msg_e"}
// Folders list: registered exactly once, non-reusable. Any second folder
// lookup (the bug) fails the test with "no stub for GET .../folders".
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
// Messages list, page 2: 2 ids, terminal.
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
// Batch metadata fetch for all 5 ids.
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
mailTriageBatchMessage("msg_a", "Subject A"),
mailTriageBatchMessage("msg_b", "Subject B"),
mailTriageBatchMessage("msg_c", "Subject C"),
mailTriageBatchMessage("msg_d", "Subject D"),
mailTriageBatchMessage("msg_e", "Subject E"),
})
args := []string{
"+triage",
"--as", "user",
"--mailbox", mailbox,
"--filter", `{"folder":"` + folderName + `"}`,
"--max", "5",
"--format", "json",
}
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
}
data := decodeMailTriageJSONOutput(t, stdout)
messages := mailTriageMessagesFromOutput(t, data)
if len(messages) != 5 {
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
}
if got := data["has_more"]; got != false {
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
}
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
// non-reusable; reg.Verify (deferred above) asserts each was matched
// exactly once. Combined with the non-reusable folders stub, this is the
// proof that the folders list API was called exactly once across both
// pages — the core invariant the fix restores.
}

View File

@@ -308,6 +308,9 @@ var MinutesSearch = common.Shortcut{
"has_more": data["has_more"],
"page_token": data["page_token"],
}
if notice, _ := data["notice"].(string); notice != "" {
outData["notice"] = notice
}
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
if len(rows) == 0 {

View File

@@ -609,6 +609,8 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
t.Parallel()
const notice = "The query is too long and has been truncated to the first 50 characters for search."
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
@@ -617,6 +619,7 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"notice": notice,
"items": []interface{}{
nil,
map[string]interface{}{
@@ -641,6 +644,9 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
reg.Verify(t)
var envelope struct {
Data struct {
Notice string `json:"notice"`
} `json:"data"`
Meta struct {
Count int `json:"count"`
} `json:"meta"`
@@ -651,6 +657,9 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
}
if envelope.Data.Notice != notice {
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
}
}
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.

View File

@@ -177,6 +177,18 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+sheet-show-gridline",
sc: SheetShowGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-hide-gridline",
sc: SheetHideGridline,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,
@@ -432,12 +444,7 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
t, tc.shortcut,
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
)
if standaloneErr == nil {
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
}
requireValidation(t, standaloneErr, tc.wantContains)
// Batch path: translate the matching sub-op. The translator wraps
// the inner error with "operations[i] (<shortcut>): " — assert the
@@ -451,17 +458,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
"input": subInput,
}
_, batchErr := translateBatchOp(rawOp, testToken, 0)
if batchErr == nil {
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(batchErr.Error(), tc.wantContains) {
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
}
batchVE := requireValidation(t, batchErr, tc.wantContains)
// And the wrap context must include the sub-op index + shortcut
// name so error reports stay actionable in multi-op batches.
wrapHint := "operations[0] (" + tc.subShortcut + "):"
if !strings.Contains(batchErr.Error(), wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
if !strings.Contains(batchVE.Message, wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchVE.Message, wrapHint)
}
})
}
@@ -517,12 +519,7 @@ func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}
@@ -580,12 +577,7 @@ func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}
@@ -716,12 +708,7 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}
@@ -782,12 +769,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
requireValidation(t, err, tc.wantContains)
})
}
}

View File

@@ -150,6 +150,12 @@ var batchOpDispatch = map[string]batchOpMapping{
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
"+sheet-show-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "show_gridline")
}},
"+sheet-hide-gridline": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide_gridline")
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},

View File

@@ -4,12 +4,10 @@
package sheets
import (
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
@@ -37,18 +35,9 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
// Bare value naming an existing file → guarded with a fix-it hint.
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
if err == nil {
t.Fatal("expected guard error when --csv names an existing file")
}
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
}
if p, ok := errs.ProblemOf(err); !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("problem = %+v, want validation/invalid_argument", p)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("guard error = %T, want *errs.ValidationError", err)
ve := requireValidation(t, err, "existing file")
if !strings.Contains(ve.Message, "@data.csv") {
t.Errorf("message should suggest @data.csv, got: %q", ve.Message)
}
if ve.Param != "--csv" {
t.Errorf("param = %q, want --csv", ve.Param)

View File

@@ -4,7 +4,6 @@
package sheets
import (
"strings"
"testing"
)
@@ -44,12 +43,7 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
"range": "A1:H17",
})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted both start-cell and range; want mutual-exclusion error")
}
if !strings.Contains(err.Error(), "--start-cell and --range are mutually exclusive") {
t.Errorf("error = %q, want it to mention start-cell/range mutual exclusion", err.Error())
}
requireValidation(t, err, "--start-cell and --range are mutually exclusive")
}
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
@@ -61,12 +55,7 @@ func TestCsvPutInput_RejectsStartCellAndRangeTogether(t *testing.T) {
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
}
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
}
requireValidation(t, err, "--start-cell or --range is required")
}
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see

View File

@@ -54,7 +54,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Insert position; appended to the end when omitted",
"desc": "Insert position (0-based); appended to the end when omitted",
"default": "-1"
},
{
@@ -413,6 +413,86 @@
}
]
},
"+sheet-hide-gridline": {
"risk": "write",
"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",
"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": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-show-gridline": {
"risk": "write",
"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",
"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": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+workbook-create": {
"risk": "write",
"flags": [
@@ -431,22 +511,33 @@
"desc": "Target folder token; placed at the drive root when omitted"
},
{
"name": "headers",
"name": "values",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
"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": [
"file",
"stdin"
]
},
{
"name": "values",
"name": "sheets",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
"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": [
"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": [
"file",
"stdin"
@@ -502,7 +593,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Local save path; export is triggered but not downloaded when omitted"
"desc": "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."
},
{
"name": "dry-run",
@@ -513,6 +604,32 @@
}
]
},
"+workbook-import": {
"risk": "write",
"flags": [
{
"name": "file",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Local file path (.xlsx / .xls / .csv)"
},
{
"name": "folder-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Target folder token; imported to the cloud drive root when omitted"
},
{
"name": "name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
}
]
},
"+sheet-info": {
"risk": "read",
"flags": [
@@ -1082,9 +1199,8 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Safety cap; default 200000",
"default": "200000",
"hidden": true
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"default": "500000"
},
{
"name": "skip-hidden",
@@ -1192,9 +1308,8 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Safety cap; default 200000",
"default": "200000",
"hidden": true
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"default": "500000"
},
{
"name": "include-row-prefix",
@@ -1212,19 +1327,65 @@
"desc": "Skip hidden rows and columns; default `false`"
},
{
"name": "rows-json",
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
}
]
},
"+table-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by id); omit to read all sheets"
},
{
"name": "sheet-name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by name); omit to read all sheets"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "no-header",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
"default": "false"
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
"desc": ""
}
]
},
@@ -1849,7 +2010,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
"input": [
"file",
"stdin"
@@ -1880,6 +2041,54 @@
}
]
},
"+table-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token to write into (XOR with `--url`)"
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Typed table payload (pandas-DataFrame-shaped) as JSON: 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\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).",
"input": [
"file",
"stdin"
]
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written 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. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+cells-clear": {
"risk": "high-risk-write",
"flags": [

View File

@@ -1,5 +1,5 @@
{
"schema_version": "2",
"schema_version": "3",
"flags": {
"+batch-update": {
"operations": {
@@ -44,6 +44,8 @@
"+sheet-hide",
"+sheet-unhide",
"+sheet-set-tab-color",
"+sheet-show-gridline",
"+sheet-hide-gridline",
"+chart-create",
"+chart-update",
"+chart-delete",
@@ -454,7 +456,7 @@
"type": "object"
},
"link": {
"description": "超链接地址(type='link' 时必填)",
"description": "超链接地址type='link' 时必填)@文档 mentionmention_type 非 0时也必填传文档 URL如搜索结果里的文档链接否则卡片不可点。@人mention_type=0不需要传",
"type": "string"
},
"mention_token": {
@@ -462,8 +464,21 @@
"type": "string"
},
"mention_type": {
"description": "@提及类型编号(仅 type='mention' 时可选)",
"type": "number"
"description": "@提及类型编号(仅 type='mention' 时可选)。0 或不填=@用户;@文件时按类型取1=文档 3=电子表格 8=多维表格 11=思维笔记 12=文件 15=旧版幻灯片 16=知识库 22=新版文档 30=幻灯片 38=画板",
"type": "number",
"enum": [
0,
1,
3,
8,
11,
12,
15,
16,
22,
30,
38
]
},
"notify": {
"description": "是否发送通知(仅 type='mention' 时可选,默认 true",
@@ -1730,11 +1745,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -1787,11 +1803,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+chart-update": {
@@ -2769,11 +2781,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -2826,11 +2839,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+cond-format-create": {
@@ -6249,6 +6258,744 @@
}
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
},
"+workbook-create": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。每个数组项的形状对齐 pandas `df.to_json(orient=\"split\")`:列名走 `columns`、二维取值走 `data`、每列的 pandas dtype 走 `dtypes`、可选的展示格式走 `formats`,并显式带上目标子表名 `name`。pandas 来源直接用 `scripts/sheets_df.py` 的 `df_to_sheet(df, name)` 生成一项,再把 list 包到 `{\"sheets\":[...]}`。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"data"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认:overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列名字符串数组,顺序与 `data` 中每行取值一一对应。同一子表内列名不可重复。",
"items": {
"type": "string"
}
},
"data": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 `columns` 数。元素按 `dtypes` 推得的列类型取值date 列写 ISO yyyy-mm-dd 字符串、number 列写数值、bool 列写布尔、其余写文本null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值date→ISO yyyy-mm-dd 字符串number→数值json.Number 精度保留bool→布尔string→文本null→空单元格。"
}
}
},
"dtypes": {
"type": "object",
"description": "可选。列名 → pandas dtype 字符串的映射;缺失项默认按 objectstring + 文本格式 `@`)处理,所以省略整段时整张表按文本写入(导入 CSV-shaped 数据的最简形态。dtype 解析规则:`int*` / `uint*` / `Int*` / `UInt*` / `float*` / `Float*` / `complex*` → number精度保留`bool` / `boolean` → bool`datetime64[ns]` / 含时区的 `datetime64[ns, UTC]` 等 → date默认 `yyyy-mm-dd` 格式),`object` / `string` / `category` / 未识别 → string + 文本格式 `@`数字样字符串如「00123」不会塌缩成数字。",
"additionalProperties": {
"type": "string"
}
},
"formats": {
"type": "object",
"description": "可选。列名 → Excel number_format 字符串的映射,覆盖 dtype 自带的默认格式(金额 `#,##0.00`、百分比 `0.0%`、自定义日期 `yyyy-mm` 等。percent 列的数值尺度由调用方负责0.0469 配 `0.00%` 显示 4.69%)。",
"additionalProperties": {
"type": "string"
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
}
}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
@@ -47,19 +48,132 @@ func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
"data": map[string]interface{}{},
},
}
stdout, stderr, err := func() (string, string, error) {
_, _, err := func() (string, string, error) {
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
reg.Register(stub)
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
err := parent.Execute()
return stdout.String(), stderr.String(), err
}()
if err == nil {
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
p := requireProblem(t, err, errs.CategoryAPI, errs.SubtypeServerError, "")
if !strings.Contains(p.Message, "1310201") && !strings.Contains(p.Message, "not found") {
t.Errorf("expected error code or message in problem; got message=%q", p.Message)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
}
// TestExecute_WikiURLResolvesToSheet covers the two-step wiki path: a /wiki/
// URL is resolved via get_node to its spreadsheet obj_token, which then feeds
// the tool invoke. The tool stub is keyed on the resolved obj_token, so the
// test would fail if the node_token were used unresolved.
func TestExecute_WikiURLResolvesToSheet(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","index":0}]}`)
out, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("sheets len = %d, want 1; out=%s", len(sheets), out)
}
}
// TestExecute_WikiURLWrongObjType rejects a wiki node that resolves to a
// non-spreadsheet obj_type before any tool invoke.
func TestExecute_WikiURLWrongObjType(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docABC",
},
},
},
}
_, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
requireValidation(t, err, "obj_type")
}
// TestExecute_WikiURLIncompleteNode treats an incomplete get_node response
// (missing obj_type/obj_token) as an internal/server error, not a user --url
// validation error.
func TestExecute_WikiURLIncompleteNode(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{},
},
},
}
_, err := runShortcutWithStubs(t, WorkbookInfo,
[]string{"--url", "https://example.feishu.cn/wiki/wikTestNODE"}, getNode)
if err == nil {
t.Fatal("want error for incomplete get_node node data")
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
t.Fatalf("incomplete-data error classified as validation (%v); want internal", err)
}
}
// TestExecute_RangeMove_WikiURL guards the transformExecuteFn path: +range-move
// and +range-copy use a named Execute helper (not an inline func), so they must
// still resolve a /wiki/ URL to the backing spreadsheet token before calling
// transform_range. The tool stub is keyed on the resolved obj_token, so an
// unresolved node_token would miss it and fail this test.
func TestExecute_RangeMove_WikiURL(t *testing.T) {
t.Parallel()
getNode := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "sheet",
"obj_token": testToken,
},
},
},
}
tool := toolOutputStub(testToken, "write", `{"updated_range":"A10:B11"}`)
out, err := runShortcutWithStubs(t, RangeMove,
[]string{
"--url", "https://example.feishu.cn/wiki/wikTestNODE",
"--sheet-id", testSheetID,
"--source-range", "A1:B2",
"--target-range", "A10",
}, getNode, tool)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
}
@@ -365,14 +479,17 @@ func TestExecute_WorkbookCreate(t *testing.T) {
},
},
}
// Initial fill first reads the workbook structure to resolve the default
// sheet's id (the create response doesn't echo it), then writes.
// The write reads the workbook structure to resolve the default sheet's id
// (the create response doesn't echo it). lookupFirstSheetID and
// writeTypedSheets' listSheetIDsByName both read it — one reusable stub serves
// both. The synthesized sheet is named "Sheet1", matching the default sheet,
// so it's adopted in place (no rename).
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Sales",
"--headers", `["Name","Score"]`,
"--values", `[["alice",95]]`,
"--values", `[["Name","Score"],["alice",95]]`,
}, create, structure, fill)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
@@ -382,8 +499,8 @@ func TestExecute_WorkbookCreate(t *testing.T) {
if ss["spreadsheet_token"] != "shtcnBRAND" {
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
}
if data["initial_fill"] == nil {
t.Errorf("initial_fill missing in envelope")
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Errorf("sheets summary missing in envelope; got %#v", data["sheets"])
}
// The fill must target the resolved first sheet, not an empty selector.
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
@@ -393,14 +510,13 @@ func TestExecute_WorkbookCreate(t *testing.T) {
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
// panic / illegal-range bug: --values '[]' must short-circuit the initial fill
// (no structure/fill calls fire) and finish with the spreadsheet created but no
// sheets summary — never panic on a nil payload.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
@@ -421,8 +537,8 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
if data["sheets"] != nil {
t.Errorf("sheets should be absent for %s %s; got %#v", tc.flag, tc.val, data["sheets"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
@@ -431,10 +547,14 @@ func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
}
}
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-state
// contract: when the spreadsheet is created but the follow-up fill can't resolve
// its first sheet, the error must be structured and retain spreadsheet_token so
// the caller can recover instead of orphaning the new workbook.
// its first sheet, the result lands on stdout as an ok:false envelope carrying
// spreadsheet_token + reason + a structured cause field, and the process exits
// with the bare partial-failure signal — matching +table-put's tablePutPartial
// shape so agents see one consistent "side effect landed but follow-up didn't"
// contract across the sheets domain (instead of the old failed_precondition
// stderr envelope).
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
@@ -448,33 +568,41 @@ func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
},
}
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
// spreadsheet already exists — exercising the partial-success path.
// spreadsheet already exists — exercising the partial-state path.
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
t.Fatalf("expected partial-failure exit signal; got nil. out=%s", out)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed problem", err)
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError exit signal; got %T %v", err, err)
}
if p.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype)
var env map[string]interface{}
if jerr := json.Unmarshal([]byte(out), &env); jerr != nil {
t.Fatalf("decode envelope: %v\nraw=%s", jerr, out)
}
if !strings.Contains(p.Message, "shtNEW") {
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
if ok, _ := env["ok"].(bool); ok {
t.Errorf("partial-state envelope must be ok:false; got out=%s", out)
}
if !strings.Contains(p.Hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
data, _ := env["data"].(map[string]interface{})
if got := data["spreadsheet_token"]; got != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW (recovery requires the token to be in the envelope)", got)
}
// The underlying fill failure is preserved as the cause so its subtype and
// log_id stay diagnosable rather than being flattened into the message.
inner := errors.Unwrap(err)
if inner == nil {
t.Fatalf("expected the underlying fill failure preserved as the cause")
reason, _ := data["reason"].(string)
if !strings.Contains(reason, "shtNEW") {
t.Errorf("reason = %q, want the spreadsheet token named for recovery", reason)
}
if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner)
hint, _ := data["hint"].(string)
if !strings.Contains(hint, "spreadsheet_token") {
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", hint)
}
// The underlying fill failure's typed shape is flattened into the cause
// field so the inner subtype stays diagnosable from the JSON envelope alone.
cause, _ := data["cause"].(map[string]interface{})
if got := cause["subtype"]; got != string(errs.SubtypeInvalidResponse) {
t.Errorf("cause.subtype = %v, want the underlying invalid_response subtype", got)
}
}

View File

@@ -80,3 +80,28 @@ func flagsFor(command string) []common.Flag {
}
return out
}
// flagAcceptsStdin reports whether the (command, flag) pair declares stdin as
// an input source in flag-defs.json. Used to decide whether an "invalid JSON"
// error should also steer the caller toward stdin. It runs on an error path,
// so it returns false for an unknown command/flag rather than panicking the
// way flagsFor does.
func flagAcceptsStdin(command, name string) bool {
defs, _ := loadFlagDefs()
spec, ok := defs[command]
if !ok {
return false
}
for _, df := range spec.Flags {
if df.Name != name {
continue
}
for _, in := range df.Input {
if in == common.Stdin {
return true
}
}
return false
}
return false
}

View File

@@ -75,7 +75,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -305,10 +305,9 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
@@ -320,7 +319,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: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -766,7 +765,7 @@ var flagDefs = map[string]commandDef{
{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: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
{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: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -793,6 +792,16 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
Risk: "read",
Flags: []flagDef{
@@ -839,6 +848,16 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-show-gridline": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
Risk: "write",
Flags: []flagDef{
@@ -895,13 +914,36 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload (pandas-DataFrame-shaped) as JSON: 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\":[...]}`. `dtypes` values are pandas dtype strings (`int64`, `float64`, `Int64`, `bool`, `boolean`, `datetime64[ns]`, `object`, ...); the writer maps them to internal string/number/date/bool — omit `dtypes` and a column writes as text (good for raw CSV-shaped data). `formats[col]` is an Excel number_format string (e.g. `#,##0.00`, `0.0%`, `yyyy-mm`); when absent, date columns default to `yyyy-mm-dd` and string columns to text format (`@`).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written 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. The styles array length/order/name must match the written sheets in --sheets.sheets. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
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: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", 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, 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"},
},
},
@@ -912,10 +954,18 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path. When omitted, **only the export task is triggered + polled, the file is NOT downloaded** (returns file_token / status so a later step can resume the download). Pass a concrete path (e.g. `./out.xlsx`) or a directory (`.` keeps the server-provided filename) to download. Note: the equivalent `lark-cli drive +export --doc-type sheet` uses three separate flags (`--output-dir` / `--file-name` / `--overwrite`) and defaults to downloading into the current directory; this wrapper collapses them into a single `--output-path` for ergonomics but defaults to no-download — fall back to `drive +export` if the split flag set fits better."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{

View File

@@ -65,9 +65,9 @@ func TestFlagsFor_MapsAllFields(t *testing.T) {
if url == nil || url.Required {
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
}
// hidden + int default
// visible + int default
cap := byName("+cells-get", "max-chars")
if cap == nil || !cap.Hidden || cap.Default != "200000" {
if cap == nil || cap.Hidden || cap.Default != "500000" {
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
}
// input sources
@@ -140,3 +140,24 @@ func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
}
}
}
// TestFlagAcceptsStdin verifies the stdin-capability probe that decides whether
// an "invalid JSON" error should also steer the caller toward stdin: a composite
// flag (cells) accepts stdin, a plain locator (spreadsheet-token) does not, and
// an unknown command/flag returns false without panicking (it runs on an error
// path, unlike flagsFor).
func TestFlagAcceptsStdin(t *testing.T) {
t.Parallel()
if !flagAcceptsStdin("+cells-set", "cells") {
t.Error("+cells-set --cells should accept stdin")
}
if flagAcceptsStdin("+cells-set", "spreadsheet-token") {
t.Error("--spreadsheet-token should not accept stdin")
}
if flagAcceptsStdin("+nope", "cells") {
t.Error("unknown command should be false (and must not panic)")
}
if flagAcceptsStdin("+cells-set", "nope") {
t.Error("unknown flag should be false")
}
}

View File

@@ -9,6 +9,8 @@ import (
"fmt"
"sort"
"sync"
"github.com/larksuite/cli/errs"
)
// ─── --print-schema runtime introspection ─────────────────────────────
@@ -91,7 +93,7 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
entry, ok := idx.Flags[command]
if !ok || len(entry) == 0 {
return nil, fmt.Errorf("no JSON Schema registered for %s", command)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
}
if flagName == "" {
flags := make([]string, 0, len(entry))
@@ -112,7 +114,9 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
flags = append(flags, f)
}
sort.Strings(flags)
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
WithParam("--flag-name")
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}

View File

@@ -84,12 +84,12 @@ func TestPrintFlagSchema_NamedFlagReturnsSchemaSubtree(t *testing.T) {
func TestPrintFlagSchema_UnknownFlagListsAvailable(t *testing.T) {
t.Parallel()
_, err := printFlagSchemaFor("+chart-create")("does-not-exist")
if err == nil {
t.Fatal("expected error for unknown flag, got nil")
ve := requireValidation(t, err, "+chart-create")
if !strings.Contains(ve.Message, "properties") {
t.Errorf("message should list available flags; got %q", ve.Message)
}
msg := err.Error()
if !strings.Contains(msg, "+chart-create") || !strings.Contains(msg, "properties") {
t.Errorf("error should mention shortcut + available flags; got %q", msg)
if ve.Param != "--flag-name" {
t.Errorf("param = %q, want --flag-name", ve.Param)
}
}

View File

@@ -63,6 +63,7 @@ func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
var parseJSONFlagSkip = map[string]struct{}{
"properties": {},
"operations": {},
"styles": {},
}
// validateValueAgainstSchema is the (command, flag) → schema → check
@@ -93,7 +94,17 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
var schema schemaProperty
json.Unmarshal(raw, &schema)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
return sheetsValidationForFlag(name, "--%s: %s", name, vErr.Error())
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
// --properties) are the highest-frequency usage-layer failure for
// sheets, and agents often burn several retries guessing the shape.
// Point them straight at --print-schema, which dumps the exact JSON
// Schema for this (command, flag) pair. The hint is always actionable:
// reaching this branch means entry[name] resolved a schema from the
// embedded index, and --print-schema reads that same index, so the
// suggested command is guaranteed to print it.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name).WithCause(vErr)
}
return nil
}

View File

@@ -478,11 +478,9 @@ func TestValidateInputAgainstSchema_RealSchema(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected enum violation, got nil")
}
if !strings.Contains(err.Error(), "summarize_by") || !strings.Contains(err.Error(), "not in enum") {
t.Errorf("error = %q, want summarize_by + enum hint", err.Error())
ve := requireValidation(t, err, "summarize_by")
if !strings.Contains(ve.Message, "not in enum") {
t.Errorf("error = %q, want enum hint", ve.Message)
}
}
@@ -499,11 +497,9 @@ func TestValidateInputAgainstSchema_RealMinItems(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected minItems violation for empty values, got nil")
}
if !strings.Contains(err.Error(), "values") || !strings.Contains(err.Error(), "minimum is 1") {
t.Errorf("error = %q, want values + minimum-is-1 hint", err.Error())
ve := requireValidation(t, err, "values")
if !strings.Contains(ve.Message, "minimum is 1") {
t.Errorf("error = %q, want minimum-is-1 hint", ve.Message)
}
}
@@ -520,11 +516,9 @@ func TestValidateInputAgainstSchema_RealMinimum(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected minimum violation for row:-1, got nil")
}
if !strings.Contains(err.Error(), "row") || !strings.Contains(err.Error(), "below minimum") {
t.Errorf("error = %q, want row + below-minimum hint", err.Error())
ve := requireValidation(t, err, "row")
if !strings.Contains(ve.Message, "below minimum") {
t.Errorf("error = %q, want below-minimum hint", ve.Message)
}
}
@@ -554,11 +548,9 @@ func TestValidateInputAgainstSchema_RealAdditionalProperties(t *testing.T) {
},
}
err := validateInputAgainstSchema(fv, bad)
if err == nil {
t.Fatal("expected additionalProperties violation, got nil")
}
if !strings.Contains(err.Error(), "collapse") || !strings.Contains(err.Error(), `expected type "string"`) {
t.Errorf("error = %q, want collapse + string-type hint", err.Error())
ve := requireValidation(t, err, "collapse")
if !strings.Contains(ve.Message, `expected type "string"`) {
t.Errorf("error = %q, want string-type hint", ve.Message)
}
}
@@ -587,3 +579,24 @@ func TestValidateInputAgainstSchema_SkipOperations(t *testing.T) {
t.Errorf("operations should be skipped; got %v", err)
}
}
// TestValidateValueAgainstSchema_PrintSchemaHint pins the highest-value
// recovery affordance for composite-JSON flags: when the shape is wrong, the
// error must point the agent straight at --print-schema (with the right
// command + flag) instead of leaving it to guess across retries. +cells-set
// --cells expects a 2-D array; a bare string trips the top-level type check.
func TestValidateValueAgainstSchema_PrintSchemaHint(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
err := validateValueAgainstSchema(fv, "cells", "not-an-array")
// Underlying shape error is preserved (substring callers still match).
ve := requireValidation(t, err, `expected type "array"`)
// And the actionable --print-schema hint is appended with the exact
// command + flag, so a copy-paste fetches the schema for this pair.
if !strings.Contains(ve.Message, "lark-cli sheets +cells-set --print-schema --flag-name cells") {
t.Errorf("want --print-schema hint with command+flag; got %q", ve.Message)
}
if ve.Param != "--cells" {
t.Errorf("param = %q, want --cells", ve.Param)
}
}

View File

@@ -32,4 +32,6 @@ var commandsWithSchema = map[string]struct{}{
"+range-sort": {},
"+sparkline-create": {},
"+sparkline-update": {},
"+table-put": {},
"+workbook-create": {},
}

View File

@@ -10,6 +10,8 @@ package sheets
import (
"context"
"encoding/json"
"fmt"
neturl "net/url"
"strings"
"github.com/larksuite/cli/errs"
@@ -48,46 +50,151 @@ func sheetsInputStatError(flag string, err error) error {
return wrapped
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR
// pair shared by every sheets canonical shortcut and returns the resolved
// token. Network-free, safe to call from Validate and DryRun.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
// spreadsheetRef classification: a --url / --spreadsheet-token input names a
// spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a
// wiki node that must be resolved to its backing spreadsheet at Execute time.
const (
spreadsheetRefSheet = "sheet"
spreadsheetRefWiki = "wiki"
)
// spreadsheetRef is a parsed --url / --spreadsheet-token input. A wiki ref holds
// the still-unresolved wiki node_token; resolveSpreadsheetTokenExec turns it
// into the real spreadsheet token at Execute time.
type spreadsheetRef struct {
Kind string // spreadsheetRefSheet | spreadsheetRefWiki
Token string
}
// parseSpreadsheetRef applies the public --url / --spreadsheet-token XOR pair and
// classifies the input. Network-free, safe to call from Validate and DryRun.
//
// Recognized --url shapes:
// - https://.../sheets/<token> → {sheet, token}
// - https://.../spreadsheets/<token> → {sheet, token}
// - https://.../wiki/<node_token> → {wiki, node_token} (resolved at Execute)
//
// A raw --spreadsheet-token is always treated as a spreadsheet token; wiki nodes
// only ever arrive as a /wiki/ URL.
func parseSpreadsheetRef(runtime *common.RuntimeContext) (spreadsheetRef, error) {
if err := common.ExactlyOneTyped(runtime, "url", "spreadsheet-token"); err != nil {
return "", err
return spreadsheetRef{}, err
}
if token := strings.TrimSpace(runtime.Str("spreadsheet-token")); token != "" {
if err := validate.RejectControlChars(token, "spreadsheet-token"); err != nil {
return "", sheetsValidationCauseForFlag("spreadsheet-token", err)
return spreadsheetRef{}, sheetsValidationCauseForFlag("spreadsheet-token", err)
}
return token, nil
return spreadsheetRef{Kind: spreadsheetRefSheet, Token: token}, nil
}
url := strings.TrimSpace(runtime.Str("url"))
token := extractSpreadsheetToken(url)
if token == "" || token == url {
return "", sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token>")
rawURL := strings.TrimSpace(runtime.Str("url"))
token, kind, ok := spreadsheetURLToken(rawURL)
if !ok {
return spreadsheetRef{}, sheetsValidationForFlag("url", "--url must be a spreadsheet URL like https://.../sheets/<token> or a wiki URL like https://.../wiki/<token>")
}
if err := validate.RejectControlChars(token, "url"); err != nil {
return "", sheetsValidationCauseForFlag("url", err)
return spreadsheetRef{}, sheetsValidationCauseForFlag("url", err)
}
return token, nil
return spreadsheetRef{Kind: kind, Token: token}, nil
}
// extractSpreadsheetToken pulls the token segment out of a /sheets/<token>
// or /spreadsheets/<token> URL. Returns the input unchanged when no known
// prefix is present (callers must check token != originalInput).
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
for _, prefix := range []string{"/sheets/", "/spreadsheets/"} {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
// spreadsheetURLToken extracts the token and its kind from a Lark URL, matching
// only on the URL *path* segment (parsed via net/url). A /wiki/ or /sheets/ that
// appears only in the query or fragment (e.g. a redirect or anchor param) never
// hijacks classification. Returns ok=false when no known prefix heads the path.
func spreadsheetURLToken(rawURL string) (token, kind string, ok bool) {
u, err := neturl.Parse(rawURL)
if err != nil || u.Path == "" {
return "", "", false
}
for _, m := range []struct {
prefix string
kind string
}{
{"/sheets/", spreadsheetRefSheet},
{"/spreadsheets/", spreadsheetRefSheet},
{"/wiki/", spreadsheetRefWiki},
} {
if seg, found := pathSegmentAfter(u.Path, m.prefix); found {
return seg, m.kind, true
}
}
return input
return "", "", false
}
// pathSegmentAfter returns the first path segment after prefix when path begins
// with prefix, else ("", false).
func pathSegmentAfter(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
if i := strings.IndexByte(rest, '/'); i >= 0 {
rest = rest[:i]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
// resolveSpreadsheetToken applies the public --url / --spreadsheet-token XOR pair
// and returns the resolved token. Network-free, safe to call from Validate and
// DryRun.
//
// A /wiki/ URL yields the still-unresolved wiki node_token: turning it into the
// backing spreadsheet token needs a get_node call, which only Execute may make.
// Validate/DryRun only need a non-empty, control-char-clean token, so the
// node_token passes through unchanged here; Execute paths call
// resolveSpreadsheetTokenExec instead.
func resolveSpreadsheetToken(runtime *common.RuntimeContext) (string, error) {
ref, err := parseSpreadsheetRef(runtime)
if err != nil {
return "", err
}
return ref.Token, nil
}
// resolveSpreadsheetTokenExec is the Execute-time counterpart of
// resolveSpreadsheetToken: it additionally resolves a /wiki/ URL's node_token to
// the backing spreadsheet token via wiki get_node, verifying obj_type=sheet.
// Non-wiki inputs make no API call. Use this from every sheets Execute hook and
// keep resolveSpreadsheetToken in Validate/DryRun so those stay network-free.
func resolveSpreadsheetTokenExec(runtime *common.RuntimeContext) (string, error) {
ref, err := parseSpreadsheetRef(runtime)
if err != nil {
return "", err
}
if ref.Kind != spreadsheetRefWiki {
return ref.Token, nil
}
return resolveWikiNodeToSpreadsheetToken(runtime, ref.Token)
}
// resolveWikiNodeToSpreadsheetToken resolves a wiki node_token to the spreadsheet
// obj_token it points at, erroring when the node is not a spreadsheet. The
// wiki:node:read scope is only needed on this path, so it is enforced here rather
// than declared unconditionally on every sheets shortcut.
func resolveWikiNodeToSpreadsheetToken(runtime *common.RuntimeContext, nodeToken string) (string, error) {
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
return "", err
}
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": nodeToken}, nil)
if err != nil {
return "", err
}
node := common.GetMap(data, "node")
objType := common.GetString(node, "obj_type")
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data for %q", nodeToken)
}
if objType != "sheet" {
return "", sheetsValidationForFlag("url", "wiki URL resolves to obj_type=%q, but a spreadsheet (obj_type=sheet) is required", objType)
}
return objToken, nil
}
// resolveSheetSelector validates the --sheet-id / --sheet-name XOR and
@@ -241,6 +348,16 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
var out interface{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
// Composite payloads that embed formulas / quotes / commas are the
// classic source of this error: inlined into the shell, the JSON gets
// mangled (e.g. `\$` → "invalid character in string escape"). For any
// flag that accepts stdin, steer the caller there — passing the payload
// via `--<flag> - < file` sidesteps shell escaping entirely.
if flagAcceptsStdin(runtime.Command(), name) {
return nil, sheetsValidationForFlag(name,
"--%s: invalid JSON: %v; if the payload contains formulas / quotes / commas, pass it via stdin (`--%s - < file`) so the shell doesn't mangle the JSON",
name, err, name).WithCause(err)
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Schema-driven flag validation at the user-input boundary. Skips
@@ -325,6 +442,72 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {

View File

@@ -81,6 +81,53 @@ func runShortcutWithStubs(t *testing.T, sc common.Shortcut, args []string, stubs
return stdout.String(), err
}
// requireProblem asserts err carries a typed errs.Problem with the given
// category and (optional) subtype, and that its message contains msgContains
// (skip the message check by passing ""). Returns the Problem so callers can
// drill into the typed envelope's category-specific fields (e.g. cast to
// *errs.ValidationError to read .Param / .Params / .Cause).
//
// Replaces the older "strings.Contains(stdout+stderr+err.Error(), ...)" pattern
// across sheets tests: substring on a rendered envelope was brittle (any
// message tweak silently broke it) and didn't verify that the typed contract —
// category / subtype / cause preservation — held. Per coding guideline
// "Error-path tests must assert typed metadata via errs.ProblemOf
// (category / subtype / param) and cause preservation, not message substrings
// alone."
func requireProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype, msgContains string) *errs.Problem {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error carrying errs.Problem, got %T: %v", err, err)
}
if p.Category != wantCategory {
t.Errorf("category = %q, want %q (err=%v)", p.Category, wantCategory, err)
}
if wantSubtype != "" && p.Subtype != wantSubtype {
t.Errorf("subtype = %q, want %q (err=%v)", p.Subtype, wantSubtype, err)
}
if msgContains != "" && !strings.Contains(p.Message, msgContains) {
t.Errorf("message = %q, want containing %q", p.Message, msgContains)
}
return p
}
// requireValidation is shorthand for the most common case: a typed
// CategoryValidation error with SubtypeInvalidArgument. Returns the
// *errs.ValidationError so callers can also assert on .Param / .Params / .Cause.
func requireValidation(t *testing.T, err error, msgContains string) *errs.ValidationError {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, msgContains)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
return ve
}
func TestSheetHelpersValidationMetadata(t *testing.T) {
t.Parallel()
@@ -268,3 +315,52 @@ const (
testSheetID = "shtSubA"
testSheetID2 = "shtSubB"
)
// TestParseSpreadsheetRef locks the network-free classification of
// --url / --spreadsheet-token into a sheet token vs an (unresolved) wiki
// node_token. The wiki node is resolved later, at Execute time only.
func TestParseSpreadsheetRef(t *testing.T) {
t.Parallel()
mk := func(url, tok string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "sheets"}
cmd.Flags().String("url", url, "")
cmd.Flags().String("spreadsheet-token", tok, "")
return common.TestNewRuntimeContext(cmd, testConfig(t))
}
cases := []struct {
name string
url string
tok string
wantKind string
wantToken string
wantErr bool
}{
{name: "sheets url", url: "https://x.feishu.cn/sheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "spreadsheets url", url: "https://x.feishu.cn/spreadsheets/shtABC", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "wiki url", url: "https://x.feishu.cn/wiki/wikDEF", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "wiki url with query", url: "https://x.feishu.cn/wiki/wikDEF?sheet=xxxxxx", wantKind: spreadsheetRefWiki, wantToken: "wikDEF"},
{name: "raw token", tok: "shtRAW", wantKind: spreadsheetRefSheet, wantToken: "shtRAW"},
{name: "sheets url with /wiki/ in query stays sheet", url: "https://x.feishu.cn/sheets/shtABC?from=/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "sheets url with /wiki/ in fragment stays sheet", url: "https://x.feishu.cn/sheets/shtABC#/wiki/wikX", wantKind: spreadsheetRefSheet, wantToken: "shtABC"},
{name: "docx url unsupported", url: "https://x.feishu.cn/docx/docABC", wantErr: true},
{name: "neither provided", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ref, err := parseSpreadsheetRef(mk(tc.url, tc.tok))
if tc.wantErr {
if err == nil {
t.Fatalf("want error, got ref=%+v", ref)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref.Kind != tc.wantKind || ref.Token != tc.wantToken {
t.Fatalf("ref = %+v, want {Kind:%s Token:%s}", ref, tc.wantKind, tc.wantToken)
}
})
}
}

View File

@@ -67,7 +67,7 @@ var BatchUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -89,8 +89,9 @@ var BatchUpdate = common.Shortcut{
}
// batchUpdateInput translates the user-supplied CLI-shape operations array
// into the MCP batch_update payload. Returns FlagErrorf-typed errors on
// any per-op shape problem (translator validates each entry).
// into the MCP batch_update payload. Returns ValidationErrorf-typed errors
// (errs.ValidationError) on any per-op shape problem (translator validates
// each entry).
func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]interface{}, error) {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {
@@ -180,7 +181,7 @@ var CellsBatchSetStyle = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -270,7 +271,7 @@ var CellsBatchClear = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -350,7 +351,7 @@ var DropdownUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -396,7 +397,7 @@ var DropdownDelete = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}

View File

@@ -5,7 +5,6 @@ package sheets
import (
"encoding/json"
"strings"
"testing"
)
@@ -166,18 +165,16 @@ func TestCellsBatchClear_Guards(t *testing.T) {
t.Parallel()
// sheetless range → prefix guard (shared with the dropdown fan-outs).
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
_, _, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["A1:A10"]`,
"--yes",
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, "must include a sheet prefix")
// missing --yes → confirmation_required (high-risk-write).
stdout, stderr, err = runShortcutCapturingErr(t, CellsBatchClear, []string{
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10"]`,
})
@@ -268,38 +265,32 @@ func TestBatchUpdate_ValidationGuards(t *testing.T) {
t.Parallel()
// dropdown-update with sheetless range
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["A2:A5"]`,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "must include a sheet prefix") {
t.Errorf("expected sheet-prefix guard for +dropdown-update; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, "must include a sheet prefix")
// batch-update with empty operations
stdout, stderr, err = runShortcutCapturingErr(t, BatchUpdate, []string{
_, _, err = runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[]`,
"--yes",
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "non-empty JSON array") {
t.Errorf("expected empty-operations guard; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, "non-empty JSON array")
// dropdown-update with non-array --options (object instead) → array guard
// (now via schema validator at parseJSONFlag time)
stdout, stderr, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
_, _, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A2"]`,
"--options", `{"not":"array"}`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), `expected type "array"`) {
t.Errorf("expected JSON array guard; got=%s|%s|%v", stdout, stderr, err)
}
requireValidation(t, err, `expected type "array"`)
}
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
@@ -322,15 +313,13 @@ func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", tc.ranges,
"--options", `["a"]`,
"--dry-run",
})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), tc.want) {
t.Errorf("ranges=%s: expected error containing %q; got=%s|%s|%v", tc.ranges, tc.want, stdout, stderr, err)
}
requireValidation(t, err, tc.want)
})
}
}
@@ -419,18 +408,13 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
if err == nil {
t.Fatalf("expected error containing %q; got stdout=%s stderr=%s", tc.wantMatch, stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tc.wantMatch) {
t.Errorf("expected error containing %q; got: %s | %s | %v", tc.wantMatch, stdout, stderr, err)
}
requireValidation(t, err, tc.wantMatch)
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/errs"
@@ -49,6 +50,20 @@ type objectCRUDSpec struct {
// right nesting level.
enhanceCreateInput func(rt flagView, input map[string]interface{})
enhanceUpdateInput func(rt flagView, input map[string]interface{})
// validateCreateInput, when set, runs after enhanceCreateInput to
// enforce cross-flag / cross-field, create-only constraints JSON
// Schema can't express. Two uses today:
// - pivot rejects --target-position vs --range when both carry
// non-default values — they map to the same wire field and
// conflicting values are ambiguous (needs raw flags via rt).
// - cond-format requires every properties.attrs entry to match the
// sibling rule_type's shape (see validateCondFormatAttrs); a
// colorScale rule fed cellIs-shaped attrs writes a color-less
// segment that breaks the sheet on open (inspects input only).
// It is the create-path twin of validateUpdateInput; the same scope
// notes apply. Validators that only inspect the wire input can ignore
// the rt argument.
validateCreateInput func(rt flagView, input map[string]interface{}) error
// validateUpdateInput, when set, runs after enhanceUpdateInput to
// enforce *cross-field, update-only* constraints JSON Schema can't
// express (e.g. sparkline requires properties.sparklines[i] to
@@ -140,7 +155,7 @@ func newObjectCreateShortcut(spec objectCRUDSpec) common.Shortcut {
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -190,6 +205,11 @@ func objectCreateInput(runtime flagView, token, sheetID, sheetName string, spec
if spec.enhanceCreateInput != nil {
spec.enhanceCreateInput(runtime, input)
}
if spec.validateCreateInput != nil {
if err := spec.validateCreateInput(runtime, input); err != nil {
return nil, err
}
}
if err := validateInputAgainstSchema(runtime, input); err != nil {
return nil, err
}
@@ -224,7 +244,7 @@ func newObjectUpdateShortcut(spec objectCRUDSpec) common.Shortcut {
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -308,7 +328,7 @@ func newObjectDeleteShortcut(spec objectCRUDSpec) common.Shortcut {
return invokeToolDryRun(token, ToolKindWrite, spec.toolName, input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -381,9 +401,6 @@ var pivotSpec = objectCRUDSpec{
},
createWarn: pivotPlacementWarn,
enhanceCreateInput: func(rt flagView, input map[string]interface{}) {
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
input["target_position"] = v
}
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return
@@ -391,10 +408,26 @@ var pivotSpec = objectCRUDSpec{
if v := strings.TrimSpace(rt.Str("source")); v != "" {
props["source"] = v
}
if v := strings.TrimSpace(rt.Str("range")); v != "" {
// --target-position 与 --range 都映射到 properties.range
// --target-position 优先,未给(或为默认值 A1时回落到 --range。
// 互斥校验在 validateCreateInput 里做。
if v := strings.TrimSpace(rt.Str("target-position")); v != "" && v != "A1" {
props["range"] = v
} else if v := strings.TrimSpace(rt.Str("range")); v != "" {
props["range"] = v
}
},
// --target-position 与 --range 落到同一 wire 字段properties.range
// 同时给非默认值时无法判断意图——按 --target-sheet-id / --target-sheet-name
// 的处理方式CLI 端直接拒绝(优于静默丢弃其一)。
validateCreateInput: func(rt flagView, _ map[string]interface{}) error {
pos := strings.TrimSpace(rt.Str("target-position"))
rng := strings.TrimSpace(rt.Str("range"))
if pos != "" && pos != "A1" && rng != "" {
return common.ValidationErrorf("--target-position and --range are mutually exclusive (both map to properties.range; pass only one)")
}
return nil
},
}
var PivotCreate = newObjectCreateShortcut(pivotSpec)
var PivotUpdate = newObjectUpdateShortcut(pivotSpec)
@@ -487,7 +520,118 @@ var condFormatSpec = objectCRUDSpec{
idField: "conditional_format_id",
enhanceCreateInput: condFormatEnhance,
enhanceUpdateInput: condFormatEnhance,
// validateCondFormatAttrs only inspects the wire input, so the create
// hook ignores rt; the update hook (func(input)) calls it directly.
validateCreateInput: func(_ flagView, input map[string]interface{}) error {
return validateCondFormatAttrs(input)
},
validateUpdateInput: validateCondFormatAttrs,
}
// condFormatAttrsRequired maps each conditional-format rule_type to the
// keys every properties.attrs entry must carry for that rule. It mirrors
// the per-rule attrs contract the tool's manage_conditional_format_object
// converter reads (byted-sheet ai-tools manage-conditional-format-object.ts):
// that converter maps each attrs entry *blindly by rule_type*, so a
// colorScale rule fed cellIs-shaped attrs ({compare_type,value}) silently
// yields a color-less color-scale segment — dirty data that crashes the
// frontend on snapshot deserialization (the 5005 "can't open" report this
// validator was added for).
//
// JSON Schema can't catch this: properties.attrs.items is a oneOf over all
// nine shapes, and the validator accepts an entry as soon as *any* branch
// matches — blind to the sibling rule_type. {compare_type,value} matches
// the cellIs branch regardless of whether rule_type says colorScale.
//
// Rule types absent from the map (duplicateValues, uniqueValues,
// containsBlanks, notContainsBlanks) carry no attrs, so nothing to check.
// Counts (dataBar==2, colorScale 23, iconSet ordering) stay the tool's
// job — it already rejects those with actionable messages; the gap this
// closes is per-entry *shape*, which the tool does not check.
var condFormatAttrsRequired = map[string][]string{
"cellIs": {"compare_type", "value"},
"containsText": {"compare_type", "text"},
"timePeriod": {"operator", "time_period"},
"dataBar": {"color", "value_type"},
"colorScale": {"value_type", "color"},
"rank": {"is_bottom", "value_type"},
"aboveAverage": {"operator"},
"expression": {"formula"},
"iconSet": {"icon_type", "value_type", "operator"},
}
// validateCondFormatAttrs enforces that every properties.attrs entry
// matches the shape required by the sibling properties.rule_type. Shared
// by create and update. On update, rule_type may be omitted (the caller is
// editing style only and the existing rule's type governs the attrs shape,
// which the CLI can't see); in that case validation is deferred to the
// server. Missing/empty attrs is likewise left to the tool, which already
// reports "attrs are required for rule_type: X" clearly.
func validateCondFormatAttrs(input map[string]interface{}) error {
props, _ := input["properties"].(map[string]interface{})
if props == nil {
return nil
}
ruleType, _ := props["rule_type"].(string)
ruleType = strings.TrimSpace(ruleType)
if ruleType == "" {
return nil
}
required, ok := condFormatAttrsRequired[ruleType]
if !ok {
return nil
}
attrs, ok := props["attrs"].([]interface{})
if !ok {
// Missing attrs, or a non-array shape the schema check already
// flagged — nothing for this cross-field rule to add.
return nil
}
for i, entryRaw := range attrs {
entry, ok := entryRaw.(map[string]interface{})
if !ok {
continue // schema validation owns per-entry type errors.
}
for _, key := range required {
if v, has := entry[key]; !has || condAttrIsBlank(v) {
return common.ValidationErrorf(
"--properties: attrs[%d] is missing %q, which rule_type %q requires on every entry (expected keys %s; got %s). "+
"A common cause is reusing another rule's attrs shape — e.g. cellIs-style {compare_type,value} under a colorScale rule, which writes a color-less segment that breaks the sheet on open.",
i, key, ruleType, strings.Join(required, "+"), condAttrPresentKeys(entry))
}
}
}
return nil
}
// condAttrIsBlank treats a present-but-empty string (after trimming) as
// missing. The crash-causing case is an empty `color`, but an empty value
// for any required key is never meaningful in these branches, so the rule
// is uniform. Non-string values (numbers, booleans) count as present.
func condAttrIsBlank(v interface{}) bool {
if v == nil {
return true
}
if s, ok := v.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
// condAttrPresentKeys lists the keys actually present on an attrs entry,
// sorted, for the "got ..." half of the error message.
func condAttrPresentKeys(entry map[string]interface{}) string {
if len(entry) == 0 {
return "{}"
}
keys := make([]string, 0, len(entry))
for k := range entry {
keys = append(keys, k)
}
sort.Strings(keys)
return "{" + strings.Join(keys, ",") + "}"
}
var CondFormatCreate = newObjectCreateShortcut(condFormatSpec)
var CondFormatUpdate = newObjectUpdateShortcut(condFormatSpec)
var CondFormatDelete = newObjectDeleteShortcut(condFormatSpec)
@@ -732,7 +876,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH
return invokeToolDryRun(token, ToolKindWrite, "manage_float_image_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -882,7 +1026,7 @@ var FilterCreate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -957,7 +1101,7 @@ var FilterUpdate = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -1025,7 +1169,7 @@ var FilterDelete = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "manage_filter_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}

View File

@@ -4,9 +4,12 @@
package sheets
import (
"encoding/json"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -137,25 +140,24 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
// covered separately in the +pivot-create empty-selector / mutex
// tests below.
{
name: "+pivot-create with placement / source / range flags",
name: "+pivot-create with placement / source / target-position flags",
sc: PivotCreate,
args: []string{
"--url", testURL, "--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--range", "F1",
"--target-position", "B5",
},
toolName: "manage_pivot_table_object",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"target_position": "B5",
"excel_id": testToken,
"sheet_id": testSheetID,
"operation": "create",
"properties": map[string]interface{}{
"rows": []interface{}{map[string]interface{}{"field": "A"}},
"source": "Sheet1!A1:F1000",
"range": "F1",
// --target-position 映射到 properties.range。
"range": "B5",
},
},
},
@@ -202,7 +204,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-id", "ruleA",
"--properties", `{"attrs":[{"operator":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
"--properties", `{"attrs":[{"compare_type":"greaterThan","value":"100"}],"style":{"back_color":"#FFD7D7"}}`,
"--rule-type", "cellIs",
"--ranges", `["A1:A100"]`,
},
@@ -214,7 +216,7 @@ func TestObjectCRUDShortcuts_DryRun(t *testing.T) {
"conditional_format_id": "ruleA",
"properties": map[string]interface{}{
"rule_type": "cellIs",
"attrs": []interface{}{map[string]interface{}{"operator": "greaterThan", "value": "100"}},
"attrs": []interface{}{map[string]interface{}{"compare_type": "greaterThan", "value": "100"}},
"style": map[string]interface{}{"back_color": "#FFD7D7"},
"ranges": []interface{}{"A1:A100"},
},
@@ -471,24 +473,18 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
t.Run("both set is rejected", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--target-sheet-name", "Sheet1",
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
})
if err == nil {
t.Fatalf("expected CLI to reject both --target-sheet-id and --target-sheet-name set; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "mutually exclusive") {
t.Errorf("expected error to say 'mutually exclusive'; got=%s|%v", stderr, err)
}
ve := requireValidation(t, err, "mutually exclusive")
// 错误信息必须用真实的 flag 名target-*),否则模型按消息提示去
// 改 --sheet-id 还是错的。
if !strings.Contains(combined, "--target-sheet-id") {
t.Errorf("expected error to quote --target-sheet-id flag name; got=%s|%v", stderr, err)
if !strings.Contains(ve.Message, "--target-sheet-id") {
t.Errorf("expected error to quote --target-sheet-id flag name; got message=%q", ve.Message)
}
})
@@ -507,6 +503,49 @@ func TestPivotCreate_SheetSelectorSemantics(t *testing.T) {
})
}
// TestPivotCreate_TargetPositionRangeMutex regresses the "--target-position
// and --range cannot both be set" guardrail on +pivot-create. They map to
// the same wire field (properties.range), so two non-default values are
// ambiguous; the CLI rejects up front (mirrors the --target-sheet-id /
// --target-sheet-name mutex). --target-position=A1 is the documented default
// and is treated as "not set" — pairing it with --range still works.
func TestPivotCreate_TargetPositionRangeMutex(t *testing.T) {
t.Parallel()
t.Run("both non-default values rejected", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--target-position", "B5",
"--range", "F1",
})
ve := requireValidation(t, err, "mutually exclusive")
if !strings.Contains(ve.Message, "--target-position") || !strings.Contains(ve.Message, "--range") {
t.Errorf("expected error to quote both --target-position and --range; got message=%q", ve.Message)
}
})
t.Run("default A1 with --range is accepted (range wins)", func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, PivotCreate, []string{
"--url", testURL,
"--target-sheet-id", testSheetID,
"--properties", `{"rows":[{"field":"A"}]}`,
"--source", "Sheet1!A1:F1000",
"--target-position", "A1",
"--range", "F1",
})
input := decodeToolInput(t, body, "manage_pivot_table_object")
props, _ := input["properties"].(map[string]interface{})
if got, _ := props["range"].(string); got != "F1" {
t.Errorf("properties.range = %q, want %q", got, "F1")
}
})
}
// TestPivotCreate_SchemaValidates exercises the schema-driven
// validator wired into objectCreateInput. The pivot create schema
// doesn't constrain rows/columns/values to be present (the backend
@@ -518,35 +557,27 @@ func TestPivotCreate_SchemaValidates(t *testing.T) {
t.Run("rejects wrong type for rows", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"rows":"not-an-array"}`,
"--source", "Sheet1!A1:F1000",
"--dry-run",
})
if err == nil {
t.Fatalf("expected schema validator to reject rows=string; stderr=%s", stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "rows") || !strings.Contains(combined, "array") {
t.Errorf("expected error to mention rows/array; got=%s|%v", stderr, err)
ve := requireValidation(t, err, "rows")
if !strings.Contains(ve.Message, "array") {
t.Errorf("expected error to mention array; got message=%q", ve.Message)
}
})
t.Run("rejects out-of-enum summarize_by", func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, PivotCreate, []string{
_, _, err := runShortcutCapturingErr(t, PivotCreate, []string{
"--url", testURL,
"--properties", `{"values":[{"field":"A","summarize_by":"BOGUS"}]}`,
"--source", "Sheet1!A1:F1000",
"--dry-run",
})
if err == nil {
t.Fatalf("expected schema validator to reject summarize_by=BOGUS; stderr=%s", stderr)
}
if !strings.Contains(stderr+err.Error(), "summarize_by") {
t.Errorf("expected error to mention summarize_by; got=%s|%v", stderr, err)
}
requireValidation(t, err, "summarize_by")
})
t.Run("schema-conformant input is accepted", func(t *testing.T) {
@@ -580,14 +611,8 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected CLI to reject empty sheet selector for +%s-create; stderr=%s", tt.name, stderr)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "specify at least one of --sheet-id or --sheet-name") {
t.Errorf("expected 'specify at least one of --sheet-id or --sheet-name'; got=%s|%v", stderr, err)
}
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
requireValidation(t, err, "specify at least one of --sheet-id or --sheet-name")
})
}
}
@@ -598,19 +623,184 @@ func TestObjectCreate_RequiresSheetSelector(t *testing.T) {
// +sparkline-list, before any server call goes out.
func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
_, _, err := runShortcutCapturingErr(t, SparklineUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--group-id", "grpA",
"--properties", `{"sparklines":[{"source":"Sheet1!A1:A10"}]}`,
})
if err == nil {
t.Fatalf("expected CLI to reject missing sparkline_id; stderr=%s", stderr)
ve := requireValidation(t, err, "missing sparkline_id")
if !strings.Contains(ve.Message, "+sparkline-list") {
t.Errorf("expected error to point at +sparkline-list; got message=%q", ve.Message)
}
combined := stderr + err.Error()
if !strings.Contains(combined, "missing sparkline_id") {
t.Errorf("expected error to mention missing sparkline_id; got=%s|%v", stderr, err)
}
// TestCondFormatAttrs_ShapeMatchesRuleType regresses the cross-field
// guard that rejects attrs whose shape doesn't match the sibling
// rule_type — the gap behind the "缺 color 的 colorScale 脏数据导致表格
// 打不开" report: a colorScale rule fed cellIs-shaped attrs
// ({compare_type,value}, no color) passed both the CLI's per-entry oneOf
// schema check and the tool, writing a color-less segment that crashed
// the frontend on open. The check covers create and update symmetrically.
func TestCondFormatAttrs_ShapeMatchesRuleType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
sc common.Shortcut
args []string
wantErr bool
wantMsg string // substring expected in the error, when wantErr
}{
{
name: "colorScale fed cellIs-shaped attrs (missing color) is rejected",
sc: CondFormatCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"},{"compare_type":"lessThan","value":"100"}]}`, "--dry-run",
},
wantErr: true,
wantMsg: "colorScale",
},
{
name: "colorScale with empty color string is rejected",
sc: CondFormatCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":""},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
},
wantErr: true,
wantMsg: `"color"`,
},
{
name: "well-formed colorScale attrs pass",
sc: CondFormatCreate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID,
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"value_type":"minValue","color":"#FFFFFF"},{"value_type":"maxValue","color":"#FF0000"}]}`, "--dry-run",
},
wantErr: false,
},
{
name: "update path is guarded too (colorScale + cellIs attrs)",
sc: CondFormatUpdate,
args: []string{
"--url", testURL, "--sheet-id", testSheetID, "--rule-id", "ruleA",
"--rule-type", "colorScale", "--ranges", `["C1:C10"]`,
"--properties", `{"style":{},"attrs":[{"compare_type":"greaterThan","value":"0"}]}`, "--dry-run",
},
wantErr: true,
wantMsg: "colorScale",
},
}
if !strings.Contains(combined, "+sparkline-list") {
t.Errorf("expected error to point at +sparkline-list; got=%s|%v", stderr, err)
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if tt.wantErr {
requireValidation(t, err, tt.wantMsg)
return
}
if err != nil {
t.Fatalf("expected acceptance (dry-run); got err=%v stderr=%s", err, stderr)
}
})
}
}
// TestCondFormatAttrsRequired_MatchesSchemaOneOf guards against drift
// between the hand-maintained condFormatAttrsRequired table (the source
// validateCondFormatAttrs enforces) and the embedded flag-schemas.json
// attrs oneOf (the authoritative shape contract synced from the spec
// repo). The cross-field validator only works if its per-rule_type
// required keys mirror the schema branches; if a future schema sync adds
// or drops a required key on any branch without updating the table, the
// CLI would silently under- or over-validate. They share no compile-time
// link, so this test is the only thing pinning them together.
//
// The schema oneOf branches are NOT labeled by rule_type (that's the whole
// point — rule_type is a sibling field the per-entry oneOf can't see), so
// we can't match branch→rule_type. We instead compare the *multiset* of
// required-key sets: every branch's required array must appear as some
// table entry's value and vice versa. This catches any added/dropped
// required key (real drift); it tolerates only a relabeling between two
// branches that happen to share an identical required set (dataBar and
// colorScale both require {color,value_type}), which is harmless here.
func TestCondFormatAttrsRequired_MatchesSchemaOneOf(t *testing.T) {
t.Parallel()
// multiset key: required keys sorted + joined, so order within a
// branch's required array doesn't matter.
keyOf := func(req []string) string {
s := append([]string(nil), req...)
sort.Strings(s)
return strings.Join(s, "+")
}
tableMS := map[string]int{}
for _, req := range condFormatAttrsRequired {
tableMS[keyOf(req)]++
}
schemaMS := func(t *testing.T, command string) map[string]int {
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas: %v", err)
}
raw, ok := idx.Flags[command]["properties"]
if !ok {
t.Fatalf("no embedded schema for %s --properties", command)
}
var schema map[string]interface{}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal %s properties schema: %v", command, err)
}
dig := func(m map[string]interface{}, key string) map[string]interface{} {
next, _ := m[key].(map[string]interface{})
if next == nil {
t.Fatalf("%s: missing %q while navigating to attrs oneOf", command, key)
}
return next
}
attrs := dig(dig(schema, "properties"), "attrs")
items := dig(attrs, "items")
oneOf, ok := items["oneOf"].([]interface{})
if !ok || len(oneOf) == 0 {
t.Fatalf("%s: attrs.items.oneOf is missing or empty", command)
}
ms := map[string]int{}
for i, branchRaw := range oneOf {
branch, ok := branchRaw.(map[string]interface{})
if !ok {
t.Fatalf("%s: oneOf[%d] is not an object", command, i)
}
reqRaw, _ := branch["required"].([]interface{})
req := make([]string, 0, len(reqRaw))
for _, r := range reqRaw {
if s, ok := r.(string); ok {
req = append(req, s)
}
}
ms[keyOf(req)]++
}
return ms
}
for _, command := range []string{"+cond-format-create", "+cond-format-update"} {
got := schemaMS(t, command)
if len(got) != len(tableMS) {
t.Errorf("%s: schema oneOf has %d distinct required-sets, table has %d", command, len(got), len(tableMS))
}
for k, n := range tableMS {
if got[k] != n {
t.Errorf("%s: required-set %q appears %d× in schema but %d× in condFormatAttrsRequired — table drifted from schema; re-sync the table", command, k, got[k], n)
}
}
for k, n := range got {
if tableMS[k] != n {
t.Errorf("%s: schema branch with required-set %q (×%d) has no matching condFormatAttrsRequired entry — add it to the table", command, k, n)
}
}
}
}
@@ -625,18 +815,13 @@ func TestSparklineUpdate_MissingSparklineID(t *testing.T) {
// create still mandates one of --image / --image-token / --image-uri.
func TestFloatImageCreate_RequiresImageSource(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
_, _, err := runShortcutCapturingErr(t, FloatImageCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--image-name", "x.png",
"--position-row", "0", "--position-col", "A",
"--size-width", "10", "--size-height", "10",
})
if err == nil {
t.Fatalf("expected CLI to require an image source on create; stderr=%s", stderr)
}
if combined := stderr + err.Error(); !strings.Contains(combined, "one of --image, --image-token, or --image-uri is required") {
t.Errorf("expected error to require an image source; got=%s|%v", stderr, err)
}
requireValidation(t, err, "one of --image, --image-token, or --image-uri is required")
}
// TestObjectDelete_AllHighRisk asserts every delete shortcut blocks
@@ -659,14 +844,8 @@ func TestObjectDelete_AllHighRisk(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, tt.args)
if err == nil {
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "confirmation_required") && !strings.Contains(combined, "requires confirmation") {
t.Errorf("expected confirmation gate; got=%s|%s|%v", stdout, stderr, err)
}
_, _, err := runShortcutCapturingErr(t, tt.sc, tt.args)
requireProblem(t, err, errs.CategoryConfirmation, errs.SubtypeConfirmationRequired, "")
})
}
}

View File

@@ -57,7 +57,7 @@ func newObjectListShortcut(spec objectListSpec) common.Shortcut {
return invokeToolDryRun(token, ToolKindRead, spec.toolName, objectListInput(runtime, token, sheetID, sheetName, spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}

View File

@@ -45,7 +45,7 @@ var CellsClear = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "clear_cell_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -163,7 +163,7 @@ func newMergeShortcut(command, desc, op string, withMergeType bool) common.Short
return invokeToolDryRun(token, ToolKindWrite, "merge_cells", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -239,7 +239,7 @@ var RowsResize = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -279,7 +279,7 @@ var ColsResize = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "resize_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -451,7 +451,7 @@ var RangeFill = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -490,7 +490,7 @@ var RangeSort = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "transform_range", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -540,7 +540,7 @@ func transformDryRunFn(op string, withPasteType, _ bool) func(context.Context, *
func transformExecuteFn(op string, withPasteType, _ bool) func(context.Context, *common.RuntimeContext) error {
return func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}

View File

@@ -287,16 +287,11 @@ func TestRangeSort_RejectsMalformedKeys(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, RangeSort, []string{
_, _, err := runShortcutCapturingErr(t, RangeSort, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:E10", "--sort-keys", c.keys, "--dry-run",
})
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), c.want) {
t.Errorf("want substring %q in error; got stdout=%s stderr=%s err=%v", c.want, stdout, stderr, err)
}
requireValidation(t, err, c.want)
})
}
}
@@ -349,13 +344,8 @@ func TestResize_TypeAndSizeGuards(t *testing.T) {
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("expected %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
_, _, err := runShortcutCapturingErr(t, tt.sc, append(tt.args, "--dry-run"))
requireValidation(t, err, tt.want)
})
}
}

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ var CellsSearch = common.Shortcut{
return invokeToolDryRun(token, ToolKindRead, "search_data", searchInput(runtime, token, sheetID, sheetName))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
@@ -122,7 +122,7 @@ var CellsReplace = common.Shortcut{
return invokeToolDryRun(token, ToolKindWrite, "replace_data", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}

View File

@@ -89,14 +89,17 @@ func TestSearchReplaceShortcuts_DryRun(t *testing.T) {
func TestCellsReplace_RequireFlag(t *testing.T) {
t.Parallel()
// --replace not passed at all (vs empty string) should error.
// --replace not passed at all (vs empty string) should error. This trips
// cobra's required-flag gate before our Validate hook runs, so the error
// is cobra's plain `required flag(s) "replacement" not set` rather than a
// typed *errs.ValidationError — keep this assertion as a substring check.
stdout, stderr, err := runShortcutCapturingErr(t, CellsReplace, []string{
"--url", testURL, "--sheet-id", testSheetID, "--find", "foo", "--dry-run",
})
if err == nil {
t.Fatalf("expected error when --replace omitted; stdout=%s stderr=%s", stdout, stderr)
t.Fatalf("expected error when --replacement omitted; stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), "replace") {
t.Errorf("expected message about --replace; got=%s|%s|%v", stdout, stderr, err)
if !strings.Contains(err.Error(), "replacement") {
t.Errorf("expected message about --replacement; got=%s|%s|%v", stdout, stderr, err)
}
}

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