Compare commits

..

44 Commits

Author SHA1 Message Date
xiongyuanwen-byted
b449614be9 chore(sheets): resync skill version 3.1.2 from sheet-skill-spec
Consumer-side artifact of sheet-skill-spec 46fb74e ("bump lark-sheets skill
版本至 3.1.2"). Frontmatter version only — the rest of SKILL.md, the references
and shortcuts/sheets/data are unchanged and stay byte-identical to the spec
repo's generated/lark-cli tree.
2026-08-02 10:16:21 +08:00
xiongyuanwen-byted
8e2f517827 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop 2026-08-01 00:52:21 +08:00
xiongyuanwen-byted
f6353368d7 fix(sheets): drop the Go 1.24-only t.Chdir, and gofmt the ergonomics test
CI's fast-gate failed on the receipt test added in 01ae4cc5: t.Chdir landed in
Go 1.24, and go.mod declares 1.23.0 — which is what CI resolves its toolchain
from. Locally it compiled against 1.24.4, so the whole suite looked green.
Replaced with the os.Chdir + t.Cleanup pattern the repo already uses elsewhere.

Also gofmt'd flag_ergonomics_test.go, whose struct literal alignment shifted
when the rejectHint field was added.

Verified by reproducing fast-gate under an actual go1.23.0 toolchain: build,
vet ./..., gofmt -l, go mod tidy, plus the -race unit tests CI runs.
2026-08-01 00:37:30 +08:00
xiongyuanwen-byted
c4270566ba test(sheets): pin the flat style flags' wire mapping, found by coverage
Coverage over the changed lines showed six of buildCellStyleFromFlags' eleven
branches never executed. TestStylesAcceptance_VocabularyParity walks the same
vocabulary but drives the PAYLOAD path; the flat --font-color / --number-format
/ … flags are a separate hand-written mapping that nothing exercised.

The failure it admits is quiet: write the wrong wire key and the request still
succeeds, the sheet just does not change. Verified the current mapping is
correct for all eleven fields before pinning it, and confirmed the test is
sensitive (renaming font_color's wire key now fails). Derived from flag-defs, so
a newly declared style flag is covered as soon as it exists.
2026-08-01 00:22:22 +08:00
xiongyuanwen-byted
75e8063bf2 test(sheets): cover --flag-name dotted-path slicing, which had none
Mutation testing found the whole feature unpinned: disabling the implicit
items/oneOf descent, or the explicit "items" segment, broke no test. It is how
SKILL.md tells agents to pull one subtree out of chart-create's ~1,750-line
properties schema instead of paging the full dump, so a regression just pushes
them back to full dumps — invisible to every output-correctness test.

Covers the one-segment walk, implicit descent through array items, the explicit
items segment, the underscore spelling of the flag name, that a slice is
strictly smaller than the full schema, and that a miss lists the keys actually
reachable at that level. Also resyncs the two read-helper script fixes from
sheet-skill-spec.
2026-08-01 00:17:36 +08:00
xiongyuanwen-byted
ae8b1a4cf3 docs(sheets): resync write-cells reference after the scattered-write routing fix 2026-08-01 00:02:53 +08:00
xiongyuanwen-byted
01ae4cc521 test(sheets): pin three guarantees that mutation testing showed nothing held
Third review pass, run as mutation testing rather than reading: break a
behaviour, see whether anything fails. Most of the suite has teeth (removing
the coalesce reordering guard, the descending sort in +dim-delete --ranges, the
sub-op vocabulary check, the cells-vs-range dimension check, the border weight
normalization, the retired-enum tolerance and the batch freeze-collision
warning all get caught). Three did not.

- coalesceStyleStamps' adjacency rule. Widening the touch test from +1 to +2
  fuses ranges separated by one row, painting cells the caller never named,
  and passed every existing case — they are all contiguous, so they only
  constrain the rule from one side. Added the negative cases plus a coverage
  property: the set of cells the expanded stamps touch must equal the set the
  spec named. Coalescing rewrites a declarative spec, so this is the invariant
  that matters, not any single fusion.

- The --output-path receipt. Hard-coding complete:true passed the whole suite,
  yet the receipt is the only completeness signal on that path (the data went
  to a file) and the skill docs tell agents to read it before using the file.
  Now driven end to end for clean / per-range-truncated / has_more reads, with
  the file contents and bytes_written checked against the receipt.

- +table-get's whole-workbook char budget. Removing the per-sheet clamp lets
  each sheet spend --max-chars in full — a 30-sheet workbook pulls 30x what
  was allowed, with every individual request looking compliant. The outer
  loop's exhaustion check is a different mechanism and kept working, which is
  why nothing failed. Now asserted on the wire: sheet 2 must ask for less than
  sheet 1.
2026-07-31 23:53:36 +08:00
xiongyuanwen-byted
99ac5d6906 fix(sheets): resync the profile-script hidden-column fix, drop a tautological test
Skill/data resync from the spec repo's matching commit: safe_append_col now
steps over hidden columns (see sheet-skill-spec), and the +batch-update
shortcut enum names +styles-put among the excluded fan-out wrappers.

Also fixes the --max-chars 0 test added in f619e34d, which asserted the value
equalled maxCharsFallback — the constant it is derived from, so it would have
passed unchanged after the flag's declared default moved in flag-defs.json and
left the two out of step. It now compares the explicit-zero call against the
omitted one, which is the actual contract ("0 means no cap of my own").
2026-07-31 23:39:47 +08:00
xiongyuanwen-byted
fd97e65b55 docs(sheets): record the live +dim-insert side check that the code assumed
The previous commit sent `side` explicitly on +dim-insert and justified it as
guarding a real hazard: that omitting it left placement to an undocumented
backend default, which if it were "after" would insert on the wrong side of
--position. Running it settles the question the other way.

On a sheet with row2 red and row3 blue, inserting at --position 3 places the
blank at row 3 under all four spellings — no `side` field at all (the pre-fix
binary), omitted, `after`, and `before` — and the blank inherits the following
row's blue under omitted/`after`, the preceding row's red under `before`. So
the backend already defaults `side` to "before", the pre-existing behaviour was
correct, and the flag's documented "omit == after" held on its own.

Sending the field explicitly is kept, but as belt-and-braces: it stops the
documented default from depending on an undocumented server-side one, and the
placement is byte-identical either way. Comments and the test doc now say that
instead of claiming a bug that was not there.
2026-07-31 23:29:59 +08:00
xiongyuanwen-byted
4e2c6a2096 docs(sheets): resync skill refs and flag data from sheet-skill-spec
Regenerated from the spec repo's matching commit: the +styles-put failure
guidance (operations[N] indexes are internal, read back and re-send instead),
--dry-run rather than --yes in the two canonical +batch-update snippets, and
the read-cap wording for --max-chars 0 / --output-path's JSON payload.
2026-07-31 23:21:03 +08:00
xiongyuanwen-byted
f619e34d38 fix(sheets): close review gaps in freeze, styles and error reporting
Review of the aggregate diff turned up nine places where the surface did not
do what this PR says it does. Each is small; the theme they share is that a
prescription pointed somewhere the caller could not follow.

Contradictions with this PR's own retirements:

- The six --frozen-* unknown-flag hints prescribed --dimension row --count N.
  Those flags are hidden from --help, so the hint named a flag missing from
  the valid-flags list printed beside it, and following it earned a
  deprecation note steering back. They now prescribe --rows / --cols, as does
  the "nothing to freeze" error.

- A Sheet! range prefix was only stripped when the styles item carried a name.
  +workbook-create --values items need none, so row_sizes like "Sheet1!2:3"
  still failed there as a malformed range — the exact bug this PR reports as
  fixed across all three --styles carriers. Stripping is now unconditional;
  only the "names a different sheet" report needs a name to compare against.

- Two +dim-freeze sub-ops in one batch cancel each other, and only the CLI can
  see it (a batch cannot read current state, and +styles-put is not batchable).
  Per-op "equivalent to --rows 1" notes never said so. A collision note now
  names the colliding ops, the state actually reached, and the single sub-op
  that holds both axes. dimFreezeAxes/dimFreezeSpelling became the shared
  mapping so the request body, the deprecation note and this one cannot drift.

- +dim-insert with --inherit-style omitted sent no `side`, so "omitting is the
  same as after" held only if the backend happened to default it to before —
  and if it defaulted to after, the insert would land on the wrong side of
  --position, silently breaking the command's stated contract. It is now sent
  explicitly; TestDimInsertOmittedMatchesAfter pins the two bodies together.

Errors that misdescribed themselves:

- "resend only operations[N:]" was emitted for every batch_update caller, but
  only +batch-update's array is caller-written. +styles-put coalesces and
  +dim-delete --ranges deliberately re-sorts descending, so the index names
  nothing the caller can locate. Those callers now get a read-back procedure.

- A sub-op carrying both an alias and its target (size + width on
  +cols-resize) was reported as an unknown input key: the alias branch fell
  through, and the conflict check never fired because keys are walked in
  sorted order and "size" sorts first. Identical values now drop the alias;
  differing ones name both spellings.

- Folding per-item failures into one error dropped each inner Hint, so the
  more mistakes a payload had, the less guidance it got — including the
  +workbook-info pointer this PR had just added. A lone issue inherits the
  hint; a folded list inlines each.

- --max-chars 0 sent no cap, which makes the read tool apply its own ~50000
  fallback: asking for no limit produced the smallest one. It now resolves to
  the same ceiling as leaving the flag alone.

Also: --inherit-style before anchors one row/column earlier, so its dry-run
showed a position the caller never typed; a note explains it is not an
off-by-one. The style vocabulary now walks sorted keys everywhere, since
every one of those loops can abort and map order decided which of several bad
fields got reported.
2026-07-31 23:20:48 +08:00
xiongyuanwen-byted
3e1623c631 docs(sheets): resync skill refs after the +styles-put routing fix
Picks up the sheet-skill-spec change that stops range-operations from
mandating +batch-update for multi-region merges, which contradicted the
routing +styles-put introduced.
2026-07-31 20:00:38 +08:00
xiongyuanwen-byted
b2551fd70b docs(sheets): resync skill entry doc after the +dim-freeze example fix
Picks up the sheet-skill-spec change that replaces the retired
--dimension/--count example in SKILL.md's quick reference with
--rows/--cols.
2026-07-31 19:50:58 +08:00
xiongyuanwen-byted
50b1f54bfa fix(sheets): make a retired enum value read as absent on both paths
Clearing a retired value left the two paths disagreeing about Changed():
the standalone path went through cobra's Set, which marks a flag as
changed, while the batch path deletes the raw key and so reports false.

--inherit-style is unaffected because its only consumers switch on the
value, but retiredEnumValues is a generic table — the next flag added to
it whose logic reads Changed() rather than the value would behave
differently standalone than inside +batch-update, and the body-parity
contract only catches such a split once it reaches the request body.

Both paths now report the flag as absent, pinned by a test that asserts
Changed() directly instead of only comparing bodies.
2026-07-31 19:41:18 +08:00
xiongyuanwen-byted
f8ed143bde fix(sheets): accept the retired --inherit-style none instead of failing
Dropping "none" from the +dim-insert enum was the only value removal in
the whole flag surface, and it broke callers: "none" was valid AND the
default before the side mapping was corrected, so existing scripts and
any agent carrying older docs pass it.

A retired value is now cleared rather than rejected, making the call
byte-identical to omitting the flag — on the standalone path and inside
+batch-update alike, since +dim-insert is batchable and a divergence
there would be invisible. It stays out of the published enum so the docs
and --help do not start teaching the redundant spelling again, and a
genuinely invalid value is still rejected.

The test mounts the shortcut through Shortcuts() rather than the exported
var: the rewrite rides the PostMount ergonomics layer, which the raw var
does not carry, so a test built on it would not exercise what ships.
2026-07-31 19:22:50 +08:00
xiongyuanwen-byted
92c5b0f26d revert: keep the event +subscribe marker out of this sheets PR
The DEPRECATED(phase-2) convention is introduced here, but retrofitting
it onto shortcuts/event/subscribe.go widened a sheets-scoped PR into an
unrelated package for a comment-only change. That retrofit moves to its
own change; the convention and the two sheets sites stay.
2026-07-31 19:01:33 +08:00
xiongyuanwen-byted
47f4fda1d3 fix(sheets): steer legacy +dim-freeze on dry-run and inside +batch-update
The phase-1 deprecation note for --dimension/--count only fired from the
standalone Execute, missing the two paths that matter most.

--dry-run is how a caller previews before committing to a spelling, so a
note that arrives only on execute arrives too late. It now rides along as
the dry-run warning_message.

+batch-update never reached the note at all, yet the batch is where the
legacy form does the most damage: freeze is full-state replacement, so
two per-axis sub-ops both report success while only the last axis stays
frozen, and +styles-put — the other way to set both axes — is not
batchable. Sub-op notes are rendered from the same helper as the
standalone one, prefixed with the operations[i] index, and DryRun and
Execute now share one batchWarnings collector so they cannot drift apart
on which advisories they report.
2026-07-31 18:57:14 +08:00
xiongyuanwen-byted
7866801115 chore: tag phase-2 deprecation sites with a greppable marker
Three surfaces are in phase-1 deprecation — executable but superseded —
and each described that state in its own prose, so there was no way to
enumerate what is pending removal.

DEPRECATED(phase-2) tags all three uniformly, each carrying its removal
checklist including the cross-repo spec step.
2026-07-31 18:41:28 +08:00
xiongyuanwen-byted
3624499bcb feat(sheets): +dim-freeze --rows/--cols for both axes in one call
Freeze is full-state replacement server-side, so --dimension/--count
unfreezes the axis it does not name. Two calls cannot hold both axes, and
inside +batch-update there was no way at all: sub-ops are a static array
that cannot read the current state, and +styles-put is not batchable.

--rows/--cols state the complete freeze state in one operation, so the
standalone command and the batch sub-op keep producing identical bodies.
The legacy pair still works and prints the exact --rows/--cols equivalent
on use, but is hidden from --help and from the skill docs.
2026-07-31 18:41:23 +08:00
xiongyuanwen-byted
13d4350557 fix(sheets): unify --styles range prefixes, merge freeze into one op
A "Sheet!" prefix on a --styles range was only normalized for +styles-put
cell_styles. row_sizes like "Sheet1!2:3" therefore failed parseA1Range on
all three carriers, while a prefix naming another sheet was rejected only
after the section parsers had already reported their own errors.

Normalization now runs in the shared item parser, so +workbook-create,
+table-put and +styles-put behave alike; a foreign prefix is stripped and
reported alongside the item's other issues instead of masking them.
Prefix stripping for visual ops moved into workbookCreateVisualOpInput,
which every caller goes through.

Freeze rows and columns now travel in one operation: the backend replaces
the whole freeze state, so two per-axis operations left only the last
axis frozen.
2026-07-31 18:41:17 +08:00
xiongyuanwen-byted
daeab10755 fix(sheets): completeness classifier, freeze alias determinism, color arrays
Fifth-round review findings, each reproduced before fixing:

P1:
- readResultTruncated missed ranges[] (the shape +cells-get / +csv-get
  return), so a clipped read could be written to a file and reported as
  complete:true. It now checks every level — top, ranges[], sheets[], and
  nesting — under all three flag names the tools use. readSheetAsSpec also
  keeps the truncation flag on the empty-grid path, where a read clipped
  before its first row used to be reported as a complete empty sheet.
- freeze accepted "cols" and "columns" as aliases and wrote both through a
  Go map range, so {"cols":1,"columns":2} froze 1 or 2 columns depending on
  randomized iteration order (observed both across 2000 runs). Conflicting
  values now reject; identical values and either alias alone still pass.
  Iteration is sorted so error text is stable too.

P2:
- the --styles / --writes aggregators returned the raw inner error when
  there was exactly one issue, leaving Param empty until a second issue
  appeared. They now attribute the outer flag and keep the cause in every
  case. checkBatchStampBudget takes the caller's flag name instead of
  always blaming --ranges.
- chart color normalization lost key context when descending into arrays,
  so schema-supported colorTheme / colorScale / highlight_colors lists kept
  bare hex and the server rejected them. isColorKey also covers plural and
  color-prefixed names; the strict hex value check keeps that safe.
- detect_subtables split records on any physical line starting with
  [row=N], so that text inside a multi-line quoted cell forged a record
  boundary and polluted row numbers. Splitting is now quote-state aware,
  and annotated mode is entered only when the first meaningful line really
  carries a prefix.
- docs: --output-path no longer described as unlimited anywhere; the
  read-data reference documents the per-sheet truncated flag and the
  offload receipt's complete / unread_sheets fields.
2026-07-31 12:08:23 +08:00
xiongyuanwen-byted
6402cb6a3a chore: drop remaining local trace report artifacts
The sheet_cli_trace_report_*.xml files are local eval artifacts swept in by
git add -A alongside the review fixes; they are not part of the PR.
2026-07-30 17:08:16 +08:00
xiongyuanwen-byted
8491775659 chore: drop accidentally committed local trace report
sheet_cli_trace_report_20260730.xml is a local eval artifact that predates
this branch; it was picked up by git add -A and does not belong in the PR.
2026-07-30 17:03:11 +08:00
xiongyuanwen-byted
f5e0d14ca9 fix(sheets): key uniqueness, order-safe style coalescing, bounded read budget
Fourth-round review findings, all reproduced before fixing:

P1 — silent wrong results:
- batch/writes key canonicalization now guarantees ONE logical key per flag.
  Both sheet-id and sheet_id used to pass the vocabulary check and survive
  into the body, where the flag view resolves either spelling — so one value
  silently won and the write could land on the wrong sheet. Conflicting
  values now reject with a typed error; identical values collapse.
- coalesceStyleStamps no longer groups globally by style content. Grouping
  reordered same-style stamps around differing ones, turning red → blue →
  red into red → blue and silently changing the final color. Two stamps are
  merged only when nothing between them touches the cells whose write would
  move earlier; adjacent and disjoint-intermediate merges still happen.
- --output-path is no longer described or treated as an unlimited read. The
  cap bounds the WHOLE read: +table-get spends one budget across all sheets
  instead of per sheet, names sheets left unread, and the stdout receipt
  now reports complete / truncated so a caller need not reopen the file.
  dry-run echoes the same max_chars / cell_limit execute sends.

P2 — contract violations:
- style payloads (--styles, typed --cells) assert the scalar types declared
  in flag-defs, so {"font_weight": true} is rejected instead of reaching
  the server as a boolean.
- aggregated --styles / --writes errors bind their flag as Param and keep
  the first underlying error as Cause, so agents read the typed envelope
  instead of parsing prose.
- detect/profile scripts handle hidden COLUMNS like hidden rows: visible-
  column adjacency stops splitting A|C into two components, and profile
  reports data_col_segments plus a data_range_has_col_gaps warning so a
  caller cannot write gap-free data back as one contiguous range.
2026-07-30 17:02:59 +08:00
xiongyuanwen-byted
240e523dbf fix(sheets): drop remaining atomic claims from --help text and errors
The earlier atomic-wording cleanup covered spec-managed docs and flag
descriptions but missed the Go-side Description / Tips fields (which
render in --help) and two sub-op rejection messages. All of these
batch_update-backed paths are fail-fast without rollback, so they now say
so instead of promising atomicity.
2026-07-30 15:46:38 +08:00
xiongyuanwen-byted
9223754af1 docs(sheets): add non-POSIX shell adaptation table for composite JSON inputs
Bash-only patterns in the skill (heredoc, POSIX quote escaping, inline
single-quoted JSON) had no PowerShell/cmd.exe branch, so a Windows agent
copying them would fail and start trial-and-error quote rewriting. One
centralized rule in the stdin section now routes non-POSIX shells:
@file (cwd-relative) as the quote-safe form everywhere, Get-Content
piping for PowerShell stdin, and no inline JSON on cmd.exe. Synced from
sheet-skill-spec.
2026-07-30 11:37:09 +08:00
xiongyuanwen-byted
3788b6f601 fix: revert retry-command renderer and temp @file exception, bound read offload
Third-round review (comprehensive CR on PR #2091) — close the P1s by
reverting the two global protocol changes and tightening the rest:

- cmdutil confirm (F1): drop the argv-rebuilt "re-run:" retry line and
  every heuristic behind it; the hint is the plain "add --yes to
  confirm" again. argv cannot faithfully reproduce the invocation
  (pipelines, stdin, redirections, env, executable path), POSIX quoting
  breaks on PowerShell/cmd.exe, and the name-based secret guard both
  leaked free-form payloads and false-positived on ordinary token
  locators (--spreadsheet-token). Callers append --yes to their own
  saved argv after user consent, per the lark-shared protocol.
- localfileio/validate/cmdutil (F4): remove the SafeTempAbsInputPath
  @file exception — TMPDIR-defined trust roots are not a security
  boundary (TMPDIR=/etc widened the allowed region) and the fast path
  bypassed the caller's FileIO provider. @file is strictly cwd-relative
  again; out-of-tree content goes through stdin.
- csv guard (F5): stop splicing the untrusted --csv value into
  command-shaped hint text; prescriptions use <path> placeholders, the
  value is only named as quoted data.
- read offload (F8): --output-path now raises max_chars to a bounded
  20M-char default instead of the 1e9 sentinel — the read path is not
  streaming, so the cap is the OOM guard; an explicit --max-chars still
  overrides.
- batch-update tips + skill (F2, synced from sheet-skill-spec): replace
  "always pass --yes" / "首次调用就带 --yes" with the consent protocol
  (dry-run, show the plan, get explicit approval, then append --yes).
- skill docs (F12, synced): scripts/lark_*.py are an optional
  enhancement — binary-embedded skills ship without scripts/, so the
  docs now say so and point at the CLI-equivalent fallback paths.
2026-07-30 11:15:53 +08:00
xiongyuanwen-byted
0e95848dd7 fix: address second-round PR #2091 review findings
Reviewer findings (fangshuyu-768) plus one CodeRabbit follow-up:

- sheets batch recovery: thread the batch's continue_on_error mode into
  flattenToolErrorMsg — a single listed failure only implies "nothing
  after it ran" under fail-fast; under continue-on-error the tail already
  executed, so prescribing operations[i:] would double-apply it
- cmdutil confirm: gate the retry line on value shape as well as flag
  name — free-form payloads (--sql, --json, non-ASCII, over-long) fall
  back to the plain add-yes hint instead of copying potential passwords
  or PII into the error envelope
- sheets read offload: map FileIO.Save failures through
  WrapSaveErrorTyped so an escaping --output-path stays a validation
  error with its path-validation cause, not internal/unknown
- styles-put: reject ranges whose sheet prefix names a different sheet
  than their item (silent strip retargeted them); reject unknown
  top-level item keys with a did-you-mean (typo'd freezee was silently
  dropped) — shared with +workbook-create / +table-put
- e2e: cover --inherit-style after in the live dim workflow
- docs/flag descs (synced from sheet-skill-spec): batch_update-backed
  paths no longer claim atomic/transactional semantics — reworded to
  fail-fast + no-rollback across +styles-put, +cells-set --writes,
  +dim-delete --ranges, resize maps and the related references
2026-07-29 18:55:57 +08:00
xiongyuanwen-byted
e41e36e1d9 chore(sheets): sync lark-sheets skill from sheet-skill-spec
Aligns +styles-put with the +batch-update execution semantics: the spec
repo's canonical reference no longer promises all-or-nothing, since the
expanded batch is fail-fast and does not roll back.

- styles-put reference: use-case + Execute contract now state fail-fast
  without rollback, and how to resend only the remaining sub-operations
  (styles / sizes / freeze are idempotent, cell_merges is not)
- SKILL.md index entry: "原子提交" -> "一次提交"

Generated from sheet-skill-spec 2a95dab via `npm run sync:cli`.
2026-07-29 17:30:25 +08:00
xiongyuanwen-byted
a5032bbb55 fix: address PR #2091 review findings across path safety, batch dispatch and read caps
Review fixes from CodeRabbit / code-quality on PR #2091:

- cmdutil: route the @/tmp fast-path read through vfs.ReadFile so fakes
  keep intercepting it; suppress the confirmation retry line when argv
  carries a credential-bearing flag (secrets stay out of error envelopes);
  extract requireConfirmationFor and pin the composed hint in tests
- localfileio: reject a degenerate TMPDIR (e.g. "/") so the temp-dir
  read exception cannot widen into accepting arbitrary absolute paths
- sheets batch dispatch: duplicate canonical + variant input keys
  (sheetName/sheet_name, ranges/range) now reject with a prescriptive
  error instead of silently overwriting the canonical value
- sheets flag ergonomics: curated unknown-flag hints match underscore
  spellings (--frozen_rows hits the --frozen-rows entry)
- sheets read offload: an explicit --max-chars survives --output-path
  instead of being replaced by the unbounded sentinel
- chart: declare --print-example in flag-defs.json (single source with
  the generated reference tables) instead of imperative registration;
  assert the validation Param in the unknown-type test
- e2e: add live +dim-insert / +dim-delete workflow coverage (insert
  lands before position, --range delete, atomic scattered --ranges)
- docs (synced from sheet-skill-spec): +chart-create flags table gains
  --print-example, freeze docs state the at-least-one-positive rule,
  read-data table gets its MD058 blank line, detect-subtables explains
  its fallback except
2026-07-29 14:24:20 +08:00
xiongyuanwen-byted
d8f6154e2f chore: add license headers to sheets skill scripts, drop redundant loop-var copies 2026-07-29 12:56:34 +08:00
xiongyuanwen-byted
d219d61be0 Merge remote-tracking branch 'origin/main' into feat/lark-sheets-develop
# Conflicts:
#	internal/validate/path.go
#	internal/vfs/localfileio/path.go
2026-07-29 12:31:44 +08:00
xiongyuanwen-byted
f79908483d fix(sheets): reject path-shaped --csv values instead of writing them into the sheet
A +csv-put --csv value naming a file that doesn't resolve used to be written
into the anchor cell as literal text, with a success exit code — a wrong value
in the sheet that nothing surfaces, which costs more than a rejection. The
common source is an absolute path: @ only reads relative paths, so the caller
drops the @ and retries, and the path string lands in A1 (07-28 root-cause
audit, finding D1). The existing guard only caught values naming a file that
does exist (the forgotten-@ case).

The guard now also rejects a value that is unmistakably path-shaped: no
separator or whitespace, pure ASCII, and either a .csv/.tsv extension or an
explicit ./ ../ / ~/ prefix. All three conditions are required — that is what
keeps prose that merely mentions a filename, N/A, README.md and CJK content
out of it, the misjudgments that retired the previous name-shape heuristic.
This flips one pinned case: a bare "nope.csv" was previously written verbatim
and now errs with the fix inlined.

To make the shape check safe for correct invocations, resolveInputFlags now
records which flags had their value replaced from @file or stdin, exposed as
RuntimeContext.InputResolvedFromSource; the guard skips resolved values
entirely. By Validate time a piped value is indistinguishable from a typed
one, so without the origin bit the guard would re-reject a correct
`--csv @file` whose content happens to look like a path — and stdin, which
the error text prescribes for verbatim writes, would not actually escape it.
The @@ escape stays inline and guarded. This origin bit is the one
common-layer addition; it carries no domain logic.
2026-07-29 12:00:47 +08:00
xiongyuanwen-byted
52b5910fb1 chore(sheets): sync lark-sheets skill and flag data from sheet-skill-spec
Mirrors `npm run sync:cli` output from the spec repo, which is the source of
truth for skill docs and flag data. Two independent changes ride along:

- The border and sheet-selector flag descriptions from spec commit 3dd7f9c,
  matching the help text already committed here in 0b595562 (data/flag-defs.json
  is byte-identical, so `go generate` is a no-op).
- The upstream read-flow work: SKILL.md 3.1.0 to 3.1.1, a longer read-data
  reference, and five read-side helper scripts under skills/lark-sheets/scripts.

The scripts land as machine resources and are not embedded in the binary
(content_embed.go whitelists docs only). make unit-test passes.
2026-07-28 21:06:13 +08:00
xiongyuanwen-byted
0b59556207 feat(sheets): name the enum, the required selector and the missing envelope in errors and help
Second batch from the 07-28 root-cause analysis, all aimed at the retry that
follows a rejection.

Enum-bearing type mismatches now answer with the allowed values instead of a
whole-payload skeleton. --border-styles with weight:1 used to reply "expected
type string, got number; expected shape: {"bottom": {…}, "left": {…}, …}",
which never mentions thin/medium/thick; the skeleton is for container-shape
confusion, so a field that declares an enum falls through to the hint that
names it. The --border-styles help now inlines both vocabularies (style is the
line type, weight the thickness and a string, not a pixel number), spells the
{all:{…}} shorthand, and states that no --border-all / --border-top /
--border-color exist. --word-wrap additionally accepts the Google Sheets
wrapStrategy words wrap and clip.

The sheet selector states that one of the pair is required rather than only
that they are mutually exclusive, in help across all shortcuts that take it,
and the rejection hints where the name comes from: a fresh workbook has one
sheet named Sheet1, any other needs a +workbook-info lookup. Eval traces
recover on the very next call, so the gap was which name to pass.

A --sheets payload written as a bare array now says the top level must be
{"sheets":[…]} instead of quoting Go's "cannot unmarshal array into Go value
of type struct { Sheets []sheets.tableSheetIn }", which names the internal
type rather than the fix. The skeleton hint is unchanged.
2026-07-28 20:47:48 +08:00
xiongyuanwen-byted
91743bba99 fix(sheets): make border vocabulary normalization reachable, prescribe flag and style-field fixes
Three fixes from the 07-28 root-cause analysis of failed agent traces, all
aimed at the first-try success rate rather than the recovery loop.

Border acceptance layer was unreachable on two of its three carrier paths.
expandBorderAllShorthand already moves a weight word out of the style slot
({"style":"thin"} -> style solid + weight thin), but on --border-styles and
typed --cells it ran after parseJSONFlag's schema check, so the enum error
fired first and the rewrite never happened. Move it ahead of validation via
the jsonFlagNormalizers seam; --styles already validated post-expansion and
is unchanged. An explicitly conflicting weight still takes the enum error.

Unknown-flag prescriptions for the names agents reach for most: +cells-set
--values, +dim-freeze --frozen-row-count and siblings, +cells-set-style
--font-bold / --bg-color / --wrap-strategy and the whole --border-* family
(no such flags; borders take one composite --border-styles). +sheet-rename
--new-name / --name alias to --title, matching +sheet-create.

Unsupported cell_styles field names now name the right field instead of the
nearest string: bold / font_bold -> font_weight, text_align ->
horizontal_alignment, a nested openpyxl-style font object -> the flat font_*
fields. Where no prescription applies, the edit-distance fallback is capped
at two edits, so a concept-swap neighbour (font_bold -> font_color, three
edits) stays silent rather than sending the retry the wrong way; a curated
prescription also drops the contradicting machine-readable suggestions.
2026-07-28 20:30:23 +08:00
xiongyuanwen-byted
ca7135f582 fix(sheets): correct +dim-insert --inherit-style side mapping, drop redundant none
The flag name was inverted relative to actual behavior, and `none` was redundant.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Synced from sheet-skill-spec (SoT).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Changes

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

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
wuyanchun.anunwu
5fb70d326a feat(sheets): 校验失败报错内联 schema 提示(报错即教学)
为什么:豆包 Excel Agent 案例中模型 7 次参数错误,报错只说"错了"不说
"怎么改对",模型反复试错并静默降级交付(5 张饼图丢数据标签)。

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

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

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

View File

@@ -310,6 +310,10 @@ lark-cli config risk-control default
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).

View File

@@ -311,6 +311,10 @@ lark-cli config risk-control default
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=larksuite/cli&type=Date)](https://star-history.com/#larksuite/cli&Date)
## 贡献
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。

View File

@@ -179,8 +179,8 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
}
// Step 1: Request app registration (begin)
// Registration is platform traffic, so it must use the provider-aware
// transport as well as the shared proxy configuration.
// Use the shared proxy-plugin-aware transport so registration traffic is not
// a bypass of proxy plugin mode.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {

View File

@@ -157,8 +157,8 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
}
}
// Connectivity checks are platform traffic and must exercise the same
// provider-aware route as real platform requests.
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
// the real egress path (and are blocked when proxy plugin fails closed).
httpClient := transport.NewHTTPClient(0)
mcpURL := ep.MCP + "/mcp"

View File

@@ -12,18 +12,9 @@ import (
"net/http"
"testing"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/envvars"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/sidecar"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// failingBody is a ReadCloser that errors on Read and tracks Close calls.
type failingBody struct {
err error
@@ -272,55 +263,3 @@ func TestInterceptor_EmptyBody(t *testing.T) {
t.Errorf("body SHA256 = %q, want empty-string SHA256 %q", sha, expectedEmpty)
}
}
func TestLegacySidecarProviderStillHandlesForcedExternalRequests(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "http://127.0.0.1:16384")
t.Setenv(envvars.CliProxyKey, "test-key")
previousProvider := exttransport.GetProvider()
exttransport.Register(&Provider{})
t.Cleanup(func() { exttransport.Register(previousProvider) })
seen := make(chan *http.Request, 2)
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
seen <- req.Clone(req.Context())
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: internaltransport.NewHTTPPolicyRouter(base, base)},
exttransport.RequestClassExternal,
)
withSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/protected", nil)
if err != nil {
t.Fatal(err)
}
withSentinel.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
resp, err := client.Do(withSentinel)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
withoutSentinel, err := http.NewRequest(http.MethodGet, "https://external.example/public", nil)
if err != nil {
t.Fatal(err)
}
resp, err = client.Do(withoutSentinel)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
proxied := <-seen
if proxied.URL.Scheme != "http" || proxied.URL.Host != "127.0.0.1:16384" {
t.Fatalf("sentinel request URL = %s, want sidecar route", proxied.URL)
}
if got := proxied.Header.Get(sidecar.HeaderProxyTarget); got != "https://external.example" {
t.Fatalf("sentinel request proxy target = %q", got)
}
passthrough := <-seen
if got := passthrough.URL.String(); got != "https://external.example/public" {
t.Fatalf("non-sentinel request URL = %q, want unchanged", got)
}
}

View File

@@ -15,27 +15,6 @@ type Provider interface {
ResolveInterceptor(ctx context.Context) Interceptor
}
// RequestClass describes the trust boundary of an outbound HTTP request.
// Platform requests target endpoints owned by the CLI's endpoint resolver;
// external requests target user-provided, pre-signed, CDN, registry, or other
// non-platform URLs. Redirect targets are classified again from each hop's
// logical URL; rewriting a host in an interceptor does not add that host to
// the platform endpoint catalog.
type RequestClass string
const (
RequestClassPlatform RequestClass = "platform"
RequestClassExternal RequestClass = "external"
)
// ScopedProvider optionally limits a Provider to selected request classes.
// Providers that do not implement this interface retain the original
// behavior and apply to every request class.
type ScopedProvider interface {
Provider
SupportsRequestClass(RequestClass) bool
}
// Interceptor defines network-layer customization via a pre/post hook pair.
// The built-in transport chain always executes between PreRoundTrip and the
// returned post function, and cannot be skipped or overridden by the extension.

View File

@@ -17,8 +17,6 @@ import (
"github.com/larksuite/cli/internal/transport"
)
var _ transport.RoundTripperDecorator = (*SecurityPolicyTransport)(nil)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
// and checks for security policy errors.
type SecurityPolicyTransport struct {
@@ -33,16 +31,6 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *SecurityPolicyTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *SecurityPolicyTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)

View File

@@ -212,9 +212,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
resp, err := httpClient.Do(httpReq)
if err != nil {
cancel()
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
}
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}

View File

@@ -518,29 +518,6 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
}
}
func TestDoStream_PreservesTypedTransportError(t *testing.T) {
policyErr := errs.NewSecurityPolicyError(errs.SubtypeAccessDenied, "blocked redirect")
ac := &APIClient{
HTTP: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, policyErr
})},
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/files/file_token/download",
}, core.AsBot)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("DoStream() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if !errors.Is(err, policyErr) {
t.Fatal("DoStream() did not preserve the typed transport error")
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{}

View File

@@ -14,12 +14,16 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// "drive.files.delete"). The hint is deliberately NOT a pre-built retry
// command: argv cannot faithfully reproduce the original invocation (pipeline
// producers, stdin bytes, redirections, inline env and the executable's real
// path are all gone), POSIX quoting does not survive PowerShell/cmd.exe, and
// echoing argv values can copy credentials or free-form payloads (--sql,
// --json) into the error envelope and every log that captures it. Per the
// lark-shared approval protocol, the caller that obtained the user's consent
// appends --yes to its own saved argv array and re-executes.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
return err.WithHint("add --yes to confirm")
}

View File

@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
// The hint is the plain add-yes contract and nothing more: no pre-built
// retry command may ride behind it (argv cannot faithfully reproduce the
// invocation and may carry sensitive payloads — see RequireConfirmation).
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
t.Errorf("Hint = %q, want exactly 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
// No fix_command field leaks into the envelope: the typed protocol stays
// action-only.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}

View File

@@ -16,12 +16,10 @@ import (
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/transport"
)
// Factory holds shared dependencies injected into every command.
@@ -33,7 +31,7 @@ type InvocationContext struct {
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // policy-routed HTTP client for direct requests
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
@@ -50,18 +48,6 @@ type Factory struct {
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ExternalHTTPClient returns a clone of the existing Factory client whose
// requests are explicitly classified as external. The underlying client,
// redirect policy, timeout, proxy configuration, and legacy transport provider
// behavior are preserved.
func (f *Factory) ExternalHTTPClient() (*http.Client, error) {
client, err := f.HttpClient()
if err != nil {
return nil, err
}
return transport.ClientForRequestClass(client, exttransport.RequestClassExternal), nil
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
// The provider controls whether the returned instance is fresh or cached.
func (f *Factory) ResolveFileIO(ctx context.Context) fileio.FileIO {

View File

@@ -5,18 +5,16 @@ package cmdutil
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
@@ -50,19 +48,6 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// workspace-scoped. Default is WorkspaceLocal — existing behavior unchanged.
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
workspaceConfig := core.NewConfigSnapshot()
bootstrapHostSignalSource := sync.OnceValue(func() riskcontrol.Source {
return resolveSDKHostSignalSource(workspaceConfig)
})
// Install after workspace selection so the dependency bootstrap bridge uses
// the correct shared proxy configuration. NewDefault is also used by cmd.Build
// consumers, so this keeps their request routing identical to cmd.Execute.
transport.InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
return buildSDKPlatformTransportWithBase(
base,
bootstrapHostSignalSource(),
)
})
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
@@ -70,6 +55,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
@@ -101,45 +87,15 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return f
}
// safeRedirectPolicy permits cross-origin redirects only for bodyless GET and
// HEAD requests. This allows API download redirects while preventing OAuth or
// other credential-bearing request bodies from being replayed to another
// origin. HTTPS requests can never be downgraded to HTTP.
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
// redirects; other headers like X-Cli-* pass through.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "too many redirects")
return fmt.Errorf("too many redirects")
}
if len(via) == 0 {
return nil
}
original := via[0]
previous := via[len(via)-1]
if previous.URL != nil && req.URL != nil && strings.EqualFold(previous.URL.Scheme, "https") && !strings.EqualFold(req.URL.Scheme, "https") {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"redirect from HTTPS to %s is not allowed",
req.URL.Scheme,
)
}
if !sameRedirectOrigin(previous.URL, req.URL) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"cross-origin redirect for HTTP method %s is not allowed",
req.Method,
)
}
if req.Body != nil || req.GetBody != nil {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"cross-origin redirect with a request body is not allowed",
)
}
}
// net/http copies initial headers onto every redirect request. Continue
// stripping credentials for every hop outside the initial origin, even when
// two consecutive redirect targets share an origin.
if !sameRedirectOrigin(original.URL, req.URL) {
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del("X-Lark-MCP-UAT")
req.Header.Del("X-Lark-MCP-TAT")
@@ -147,29 +103,6 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
return nil
}
func sameRedirectOrigin(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
effectivePort(left) == effectivePort(right)
}
func effectivePort(candidate *url.URL) string {
if port := candidate.Port(); port != "" {
return port
}
switch strings.ToLower(candidate.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
// warnIfProxied is a test seam for the proxy-warning gate. Production wires it
// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is
// needed because the real function is guarded by an internal sync.Once, so
@@ -185,12 +118,15 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
shared := transport.Shared()
outbound := riskcontrol.NewTransport(shared, hostSignalSource)
platform := buildDirectHTTPTransport(outbound, true)
external := buildDirectHTTPTransport(outbound, false)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
rt = wrapWithExtension(rt)
client := &http.Client{
Transport: transport.NewHTTPPolicyRouter(platform, external),
Transport: rt,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
@@ -198,15 +134,6 @@ func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func buildDirectHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
var builtIn http.RoundTripper = &RetryTransport{Base: base}
builtIn = &SecurityHeaderTransport{Base: builtIn}
if platform {
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
}
return builtIn
}
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
@@ -222,8 +149,14 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(hostSignalSource),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -232,41 +165,12 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
})
}
func buildSDKTransport(hostSignalSource riskcontrol.Source) http.RoundTripper {
return buildSDKTransportWithBase(transport.Shared(), hostSignalSource)
}
func buildSDKPlatformTransportWithBase(
base http.RoundTripper,
hostSignalSource riskcontrol.Source,
) http.RoundTripper {
outbound := riskcontrol.NewTransport(base, hostSignalSource)
return buildSDKHTTPTransport(outbound, true)
}
func buildSDKTransportWithBase(
base http.RoundTripper,
hostSignalSource riskcontrol.Source,
) http.RoundTripper {
// Risk control is the innermost trusted boundary for both request classes.
// It therefore observes the final URL and strips extension-supplied reserved
// headers immediately before the network transport.
outbound := riskcontrol.NewTransport(base, hostSignalSource)
return transport.NewHTTPPolicyRouter(
buildSDKHTTPTransport(outbound, true),
buildSDKHTTPTransport(outbound, false),
)
}
func buildSDKHTTPTransport(base http.RoundTripper, platform bool) http.RoundTripper {
var builtIn http.RoundTripper = &RetryTransport{Base: base}
builtIn = &UserAgentTransport{Base: builtIn}
builtIn = &BuildHeaderTransport{Base: builtIn}
builtIn = &SecurityHeaderTransport{Base: builtIn}
if platform {
builtIn = &auth.SecurityPolicyTransport{Base: builtIn}
}
return builtIn
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
return wrapWithExtension(sdkTransport)
}
type credentialDeps struct {

View File

@@ -4,20 +4,13 @@
package cmdutil
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
@@ -40,7 +33,7 @@ func TestCachedHTTPClientFunc_ReturnsSameInstance(t *testing.T) {
}
}
func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
@@ -51,7 +44,7 @@ func TestCachedHTTPClientFunc_HasTimeout(t *testing.T) {
}
}
func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
@@ -61,283 +54,3 @@ func TestCachedHTTPClientFunc_HasRedirectPolicy(t *testing.T) {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
}
}
func TestFactoryExternalHTTPClientClonesExistingClient(t *testing.T) {
base := &http.Client{Timeout: 17, CheckRedirect: safeRedirectPolicy}
factory := &Factory{HttpClient: func() (*http.Client, error) { return base, nil }}
external, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
if external == base {
t.Fatal("ExternalHTTPClient returned the cached client instead of a clone")
}
if external.Timeout != base.Timeout || external.CheckRedirect == nil {
t.Fatal("ExternalHTTPClient did not preserve client policy")
}
if base.Transport != nil {
t.Fatal("ExternalHTTPClient mutated the cached client's transport")
}
}
type platformOnlyStubProvider struct {
*stubTransportProvider
}
func (*platformOnlyStubProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassPlatform
}
func TestFactoryHTTPClientRoutesPoliciesByRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
interceptor := &headerCapturingInterceptor{}
exttransport.Register(&platformOnlyStubProvider{stubTransportProvider: &stubTransportProvider{interceptor: interceptor}})
t.Cleanup(func() { exttransport.Register(nil) })
received := make(chan http.Header, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Clone()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
client, err := cachedHttpClientFunc(factory, nil)()
if err != nil {
t.Fatal(err)
}
factory.HttpClient = func() (*http.Client, error) { return client, nil }
platformClient := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
externalClient, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
for _, client := range []*http.Client{platformClient, externalClient} {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
platformHeaders := <-received
if got := platformHeaders.Get("X-Custom-Trace"); got != "ext-trace-123" {
t.Fatalf("platform extension header = %q, want ext-trace-123", got)
}
if got := platformHeaders.Get(HeaderSource); got != SourceValue {
t.Fatalf("platform security header = %q, want %q", got, SourceValue)
}
externalHeaders := <-received
if got := externalHeaders.Get("X-Custom-Trace"); got != "" {
t.Fatalf("external request leaked extension header %q", got)
}
for header, values := range BaseSecurityHeaders() {
if len(values) == 0 {
continue
}
want := values[len(values)-1]
if got := externalHeaders.Get(header); got != want {
t.Fatalf("external security header %s = %q, want preserved value %q", header, got, want)
}
}
}
func TestFactoryExternalHTTPClientDoesNotParsePlatformErrorProtocol(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_PROXY", "1")
exttransport.Register(nil)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":21000,"msg":"application-defined external response","data":{"cli_hint":"external-defined"}}`)
}))
t.Cleanup(server.Close)
factory := &Factory{IOStreams: &IOStreams{ErrOut: io.Discard}}
client, err := cachedHttpClientFunc(factory, nil)()
if err != nil {
t.Fatal(err)
}
factory.HttpClient = func() (*http.Client, error) { return client, nil }
platform := internaltransport.ClientForRequestClass(client, exttransport.RequestClassPlatform)
if _, err := platform.Get(server.URL); err == nil {
t.Fatal("platform request error = nil, want security policy classification")
} else {
var policyErr *errs.SecurityPolicyError
if !errors.As(err, &policyErr) {
t.Fatalf("platform request error type = %T, want *errs.SecurityPolicyError", err)
}
}
external, err := factory.ExternalHTTPClient()
if err != nil {
t.Fatal(err)
}
resp, err := external.Get(server.URL)
if err != nil {
t.Fatalf("external request parsed platform error protocol: %v", err)
}
resp.Body.Close()
}
func TestSafeRedirectPolicyAllowsBodylessCrossOriginGetAndStripsCredentials(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/file", nil)
if err != nil {
t.Fatal(err)
}
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
redirect.Header.Set(header, "secret")
}
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want allowed GET redirect", err)
}
for _, header := range []string{"Authorization", "X-Lark-MCP-UAT", "X-Lark-MCP-TAT"} {
if got := redirect.Header.Get(header); got != "" {
t.Fatalf("redirect retained %s=%q", header, got)
}
}
}
func TestSafeRedirectPolicyRejectsHTTPSDowngrade(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "http://open.feishu.cn/next", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("safeRedirectPolicy() error = %v, want HTTPS downgrade rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsCrossOriginMethod(t *testing.T) {
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodPost, "https://external.example/token", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "HTTP method POST") {
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin method rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsCrossOriginRequestBody(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://accounts.feishu.cn/token", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://external.example/token", strings.NewReader("client_secret=secret"))
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original})
if err == nil || !strings.Contains(err.Error(), "request body") {
t.Fatalf("safeRedirectPolicy() error = %v, want cross-origin body rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func TestSafeRedirectPolicyRejectsTooManyRedirects(t *testing.T) {
err := safeRedirectPolicy(&http.Request{}, make([]*http.Request, 10))
if err == nil || err.Error() != "too many redirects" {
t.Fatalf("safeRedirectPolicy() error = %v, want redirect limit rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryNetwork, errs.SubtypeNetworkTransport)
}
func TestSafeRedirectPolicyTreatsDefaultHTTPSPortAsSameOrigin(t *testing.T) {
original, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn/token", strings.NewReader("secret"))
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodPost, "https://accounts.feishu.cn:443/token-next", strings.NewReader("secret"))
if err != nil {
t.Fatal(err)
}
if err := safeRedirectPolicy(redirect, []*http.Request{original}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want same-origin redirect", err)
}
}
func TestSafeRedirectPolicyKeepsCredentialsStrippedAcrossExternalHops(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/first", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/second", nil)
if err != nil {
t.Fatal(err)
}
redirect.Header.Set("Authorization", "Bearer copied-from-initial-request")
if err := safeRedirectPolicy(redirect, []*http.Request{original, previous}); err != nil {
t.Fatalf("safeRedirectPolicy() error = %v, want same-CDN redirect", err)
}
if got := redirect.Header.Get("Authorization"); got != "" {
t.Fatalf("redirect retained Authorization=%q outside the initial origin", got)
}
}
func TestSafeRedirectPolicyRejectsDowngradeOnLaterHop(t *testing.T) {
original, err := http.NewRequest(http.MethodGet, "http://source.example/start", nil)
if err != nil {
t.Fatal(err)
}
previous, err := http.NewRequest(http.MethodGet, "https://cdn.example.com/secure", nil)
if err != nil {
t.Fatal(err)
}
redirect, err := http.NewRequest(http.MethodGet, "http://cdn.example.com/plain", nil)
if err != nil {
t.Fatal(err)
}
err = safeRedirectPolicy(redirect, []*http.Request{original, previous})
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("safeRedirectPolicy() error = %v, want later-hop HTTPS downgrade rejection", err)
}
requireRedirectProblem(t, err, errs.CategoryPolicy, errs.SubtypeAccessDenied)
}
func requireRedirectProblem(t *testing.T, err error, category errs.Category, subtype errs.Subtype) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error type = %T, want typed error", err)
}
if problem.Category != category || problem.Subtype != subtype {
t.Fatalf(
"error category/subtype = %s/%s, want %s/%s",
problem.Category,
problem.Subtype,
category,
subtype,
)
}
}

View File

@@ -34,9 +34,9 @@ var proxyWarnGateCases = []struct {
{"non-terminal stderr stays silent", false, 0},
}
// TestCachedHTTPClientFunc_ProxyWarnGate verifies the HTTP client init path
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHTTPClientFunc_ProxyWarnGate(t *testing.T) {
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -77,6 +77,11 @@ func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, er
// ReadInputFile reads path through fileIO. Open/read failures are wrapped with
// path context; fileio.ErrPathValidation remains matchable with errors.Is.
// All paths go through the caller's fileIO provider and its relative-to-cwd
// policy — no absolute-path side door: a trust root defined by the process
// environment (TMPDIR) is not a security boundary, and reading outside the
// provider would break sidecar/custom-FileIO ownership. Out-of-tree content
// reaches flags via stdin ("-").
func ReadInputFile(fileIO fileio.FileIO, path string) ([]byte, error) {
if fileIO == nil {
return nil, fmt.Errorf("file input is not available in this context")

View File

@@ -46,7 +46,7 @@ func TestTestFactory_ReplacesGlobals(t *testing.T) {
URL: "/test",
Body: "ok",
})
// Use the stub via Factory HttpClient.
// Use the stub via Factory HttpClient
httpClient, err := f.HttpClient()
if err != nil {
t.Fatalf("HttpClient() error: %v", err)

View File

@@ -4,19 +4,14 @@
package cmdutil
import (
"context"
"net/http"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/transport"
)
var (
_ transport.RoundTripperDecorator = (*RetryTransport)(nil)
_ transport.RoundTripperDecorator = (*UserAgentTransport)(nil)
_ transport.RoundTripperDecorator = (*BuildHeaderTransport)(nil)
_ transport.RoundTripperDecorator = (*SecurityHeaderTransport)(nil)
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
// and network errors. MaxRetries defaults to 0 (no retries).
type RetryTransport struct {
@@ -32,16 +27,6 @@ func (t *RetryTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *RetryTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *RetryTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *RetryTransport) delay() time.Duration {
if t.Delay > 0 {
return t.Delay
@@ -78,19 +63,6 @@ type UserAgentTransport struct {
Base http.RoundTripper
}
func (t *UserAgentTransport) BaseRoundTripper() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *UserAgentTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderUserAgent, UserAgentValue())
@@ -101,25 +73,14 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
}
// BuildHeaderTransport is an http.RoundTripper that force-writes the
// X-Cli-Build header before every request. It remains in the SDK transport
// chain as a narrow defense-in-depth layer alongside SecurityHeaderTransport.
// X-Cli-Build header before every request. Used in the SDK transport chain,
// where SecurityHeaderTransport is not installed, to prevent extensions from
// tampering with the build classification. The direct HTTP chain is already
// covered by SecurityHeaderTransport iterating BaseSecurityHeaders.
type BuildHeaderTransport struct {
Base http.RoundTripper
}
func (t *BuildHeaderTransport) BaseRoundTripper() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return transport.Fallback()
}
func (t *BuildHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderBuild, DetectBuildKind())
@@ -142,16 +103,6 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
return transport.Fallback()
}
func (t *SecurityHeaderTransport) BaseRoundTripper() http.RoundTripper {
return t.base()
}
func (t *SecurityHeaderTransport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.Base = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
@@ -169,3 +120,67 @@ func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response,
}
return t.base().RoundTrip(req)
}
// extensionMiddleware wraps the built-in transport chain with pre/post hooks.
// The built-in chain always executes unless the extension is an
// exttransport.AbortableInterceptor and its PreRoundTripE returns a non-nil
// error; it cannot otherwise be skipped or overridden.
//
// The original request context is restored after the pre hook to prevent
// extensions from tampering with cancellation, deadlines, or built-in values.
// Cloning the request isolates header/URL/etc. mutations from the caller's
// request object; req.Body is intentionally shared — extensions that consume
// it are responsible for rewinding (see Interceptor doc).
type extensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string // Provider.Name(), captured at wrap time for *AbortError.Extension
}
// RoundTrip invokes the interceptor pre hook, restores the original context,
// executes the built-in chain (unless aborted), then calls the post hook if
// non-nil. When the extension implements AbortableInterceptor and returns a
// non-nil error from PreRoundTripE, the built-in chain is skipped and an
// *exttransport.AbortError is returned; the post hook is still invoked with
// (nil, reason) so extensions can unwind resources.
func (m *extensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortEr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortEr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortEr != nil {
if post != nil {
post(nil, abortEr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortEr}
}
req = req.WithContext(origCtx) // restore original context
resp, err := m.Base.RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// wrapWithExtension wraps transport with the registered extension middleware.
// If no extension is registered, returns transport unchanged.
func wrapWithExtension(transport http.RoundTripper) http.RoundTripper {
p := exttransport.GetProvider()
if p == nil {
return transport
}
tr := p.ResolveInterceptor(context.Background())
if tr == nil {
return transport
}
return &extensionMiddleware{Base: transport, Ext: tr, ExtName: p.Name()}
}

View File

@@ -14,8 +14,8 @@ import (
"time"
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
internaltransport "github.com/larksuite/cli/internal/transport"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,107 +91,94 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
}
// buildSDKTransport policy behavior
// ---------------------------------------------------------------------------
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransportAppliesSecurityHeadersToEveryRequestClass(t *testing.T) {
exttransport.Register(nil)
received := make(chan http.Header, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Clone()
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
for _, class := range []exttransport.RequestClass{
exttransport.RequestClassPlatform,
exttransport.RequestClassExternal,
} {
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransport(nil)},
class,
)
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
headers := <-received
for header, values := range BaseSecurityHeaders() {
if len(values) == 0 {
continue
}
want := values[len(values)-1]
if got := headers.Get(header); got != want {
t.Fatalf("SDK %s header %s = %q, want %q", class, header, got, want)
}
}
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
interceptor := &headerCapturingInterceptor{}
exttransport.Register(&platformOnlyStubProvider{
stubTransportProvider: &stubTransportProvider{interceptor: interceptor},
})
t.Cleanup(func() { exttransport.Register(previous) })
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransport(nil)},
exttransport.RequestClassPlatform,
)
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if !interceptor.preCalled || !interceptor.postCalled {
t.Fatal("SDK platform request did not execute extension pre/post hooks")
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
if _, ok := buildSDKTransport(nil).(*internaltransport.HTTPPolicyRouter); !ok {
t.Fatalf(
"buildSDKTransport() type = %T, want *transport.HTTPPolicyRouter",
buildSDKTransport(nil),
)
}
}
func TestBuildSDKTransportSupportsPolicyLeafCloning(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
base := &http.Transport{}
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransportWithBase(base, nil)},
exttransport.RequestClassExternal,
)
source, ok := client.Transport.(interface {
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
})
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("SDK request-class transport type = %T, want clone capability", client.Transport)
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
rebuilt, concrete, ok := source.CloneHTTPTransport()
if !ok || rebuilt == nil || concrete == nil {
t.Fatal("SDK policy graph could not clone its HTTP transport leaf")
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
if concrete == base {
t.Fatal("SDK policy graph reused the original HTTP transport")
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(previous) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
}
sec, ok := mid.Base.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("transport type = %T, want *auth.SecurityPolicyTransport", mid.Base)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
}
bh, ok := sec.Base.(*BuildHeaderTransport)
if !ok {
t.Fatalf("layer after SecurityPolicy = %T, want *BuildHeaderTransport", sec.Base)
}
ua, ok := bh.Base.(*UserAgentTransport)
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
@@ -251,7 +238,7 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := internaltransport.WrapWithExtension(base)
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
@@ -279,16 +266,14 @@ func TestExtensionInterceptor_ExecutionOrder(t *testing.T) {
}
}
// buildTamperingInterceptor tries to delete and spoof security headers via
// PreRoundTrip. The SDK built-in chain must restore the real values before the
// request leaves the process.
// buildTamperingInterceptor tries to delete and spoof X-Cli-Build via
// PreRoundTrip. The SDK chain's BuildHeaderTransport must restore the real
// value before the request leaves the process.
type buildTamperingInterceptor struct{}
func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Del(HeaderBuild)
req.Header.Set(HeaderBuild, "ext-tampered-build")
req.Header.Del(HeaderSource)
req.Header.Set(HeaderSource, "ext-tampered-source")
return nil
}
@@ -300,74 +285,7 @@ func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http
return nil
}
type bootstrapPolicyTamperingInterceptor struct{}
func (bootstrapPolicyTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(HeaderSource, "extension-value")
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
return nil
}
func TestNewDefaultInstallsSDKBootstrapSecurityPolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldTransport := http.DefaultClient.Transport
oldCheckRedirect := http.DefaultClient.CheckRedirect
t.Cleanup(func() {
http.DefaultClient.Transport = oldTransport
http.DefaultClient.CheckRedirect = oldCheckRedirect
})
previous := exttransport.GetProvider()
exttransport.Register(&platformOnlyStubProvider{
stubTransportProvider: &stubTransportProvider{
interceptor: bootstrapPolicyTamperingInterceptor{},
},
})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})
http.DefaultClient.Transport = network
http.DefaultClient.CheckRedirect = nil
_ = NewDefault(nil, InvocationContext{})
req, err := http.NewRequest(
http.MethodPost,
"https://open.feishu.cn/callback/ws/endpoint",
strings.NewReader(`{"app_secret":"secret"}`),
)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := received.Get(HeaderSource); got != SourceValue {
t.Fatalf("%s = %q, want trusted value %q", HeaderSource, got, SourceValue)
}
if got := received.Get(riskcontrol.HeaderOSType); got != "" {
t.Fatalf("%s = %q, want extension value stripped", riskcontrol.HeaderOSType, got)
}
if got := received.Get(HeaderBuild); got != DetectBuildKind() {
t.Fatalf("%s = %q, want %q", HeaderBuild, got, DetectBuildKind())
}
if got := received.Get(HeaderUserAgent); got != UserAgentValue() {
t.Fatalf("%s = %q, want %q", HeaderUserAgent, got, UserAgentValue())
}
}
func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
@@ -383,11 +301,7 @@ func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
}
req.Header.Set("Authorization", "Bearer token")
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: buildSDKTransportWithBase(network, nil)},
exttransport.RequestClassPlatform,
)
resp, err := client.Do(req)
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
@@ -398,13 +312,14 @@ func TestBuildSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// SDK chain restores both the build classification and the full security
// header set after an extension runs.
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
// closes the gap where the SDK chain had no equivalent of
// SecurityHeaderTransport (see design doc §3.3.3).
func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
var receivedBuild, receivedSource string
var receivedBuild string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedBuild = r.Header.Get(HeaderBuild)
receivedSource = r.Header.Get(HeaderSource)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
@@ -412,13 +327,12 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK built-in chain inside buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}
base = &BuildHeaderTransport{Base: base}
base = &SecurityHeaderTransport{Base: base}
transport := internaltransport.WrapWithExtension(base)
transport := wrapWithExtension(base)
client := &http.Client{Transport: transport}
req, _ := http.NewRequest("GET", srv.URL, nil)
@@ -435,9 +349,6 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
if receivedBuild != want {
t.Fatalf("%s = %q, want %q", HeaderBuild, receivedBuild, want)
}
if receivedSource != SourceValue {
t.Fatalf("%s = %q, want %q", HeaderSource, receivedSource, SourceValue)
}
}
// TestBuildHeaderTransport_OverridesEvenWithoutTamper verifies that even if
@@ -527,7 +438,7 @@ func TestExtensionInterceptor_ContextTamperPrevented(t *testing.T) {
return nil
})
mid := &internaltransport.ExtensionMiddleware{Base: capturer, Ext: tamperIC}
mid := &extensionMiddleware{Base: capturer, Ext: tamperIC}
origCtx := context.WithValue(context.Background(), testKey, "original")
req, _ := http.NewRequestWithContext(origCtx, "GET", srv.URL, nil)
@@ -589,7 +500,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
@@ -630,7 +541,7 @@ func TestExtensionMiddleware_PreRoundTripEAbort(t *testing.T) {
return nil, nil
})
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
_, err := mid.RoundTrip(req)
@@ -649,7 +560,7 @@ func TestExtensionMiddleware_PreRoundTripEHappyPath(t *testing.T) {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
mid := &internaltransport.ExtensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
mid := &extensionMiddleware{Base: base, Ext: ic, ExtName: "stub"}
req, _ := http.NewRequest("GET", "http://example.invalid/", nil)
resp, err := mid.RoundTrip(req)
if err != nil {

View File

@@ -3,10 +3,7 @@
package core
import (
"net/url"
"strings"
)
import "strings"
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
@@ -66,39 +63,3 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}
var platformEndpointHosts = func() map[string]struct{} {
hosts := make(map[string]struct{})
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
endpoints := ResolveEndpoints(brand)
for _, rawURL := range []string{endpoints.Open, endpoints.Accounts, endpoints.MCP, endpoints.AppLink} {
parsed, err := url.Parse(rawURL)
if err == nil && parsed.Hostname() != "" {
hosts[strings.ToLower(parsed.Hostname())] = struct{}{}
}
}
}
return hosts
}()
// IsPlatformEndpointHost reports whether hostname exactly matches one of the
// endpoint hosts produced by ResolveEndpoints. It intentionally does not use a
// suffix match: lookalike external domains must never enter the platform
// transport extension.
func IsPlatformEndpointHost(hostname string) bool {
_, ok := platformEndpointHosts[strings.ToLower(hostname)]
return ok
}
// IsPlatformEndpointURL reports whether candidate uses a secure origin for a
// configured platform endpoint. Non-TLS and non-standard-port lookalikes are
// excluded even when their hostname matches.
func IsPlatformEndpointURL(candidate *url.URL) bool {
if candidate == nil || !strings.EqualFold(candidate.Scheme, "https") {
return false
}
if port := candidate.Port(); port != "" && port != "443" {
return false
}
return IsPlatformEndpointHost(candidate.Hostname())
}

View File

@@ -3,11 +3,7 @@
package core
import (
"net/url"
"reflect"
"testing"
)
import "testing"
func TestResolveEndpoints_Feishu(t *testing.T) {
ep := ResolveEndpoints(BrandFeishu)
@@ -95,85 +91,3 @@ func TestResolveEndpoints_NormalizesBrand(t *testing.T) {
t.Errorf("ResolveEndpoints(unexpected).Open = %q, want the feishu default", got)
}
}
func TestIsPlatformEndpointHost_ExactMatchOnly(t *testing.T) {
for _, host := range []string{
"open.feishu.cn",
"accounts.feishu.cn",
"mcp.feishu.cn",
"applink.feishu.cn",
"open.larksuite.com",
"accounts.larksuite.com",
"mcp.larksuite.com",
"applink.larksuite.com",
} {
if !IsPlatformEndpointHost(host) {
t.Errorf("IsPlatformEndpointHost(%q) = false, want true", host)
}
}
for _, host := range []string{
"example.com",
"open.feishu.cn.example.com",
"notopen.feishu.cn",
"",
} {
if IsPlatformEndpointHost(host) {
t.Errorf("IsPlatformEndpointHost(%q) = true, want false", host)
}
}
}
func TestIsPlatformEndpointHost_CoversEveryResolvedEndpoint(t *testing.T) {
for _, brand := range []LarkBrand{BrandFeishu, BrandLark} {
endpoints := reflect.ValueOf(ResolveEndpoints(brand))
for i := 0; i < endpoints.NumField(); i++ {
rawURL := endpoints.Field(i).String()
parsed, err := url.Parse(rawURL)
if err != nil {
t.Fatalf("ResolveEndpoints(%q) field %d URL %q: %v", brand, i, rawURL, err)
}
if !IsPlatformEndpointHost(parsed.Hostname()) {
t.Errorf("ResolveEndpoints(%q) field %d host %q is missing from the platform transport boundary", brand, i, parsed.Hostname())
}
}
}
}
func TestIsPlatformEndpointURL_RequiresSecureStandardOrigin(t *testing.T) {
if IsPlatformEndpointURL(nil) {
t.Error("IsPlatformEndpointURL(nil) = true, want false")
}
uppercaseScheme := &url.URL{Scheme: "HTTPS", Host: "open.feishu.cn", Path: "/path"}
if !IsPlatformEndpointURL(uppercaseScheme) {
t.Error("IsPlatformEndpointURL() rejected uppercase HTTPS scheme")
}
for _, rawURL := range []string{
"http://open.feishu.cn/path",
"https://open.feishu.cn:8443/path",
"https://open.feishu.cn.example.com/path",
} {
candidate, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
if IsPlatformEndpointURL(candidate) {
t.Errorf("IsPlatformEndpointURL(%q) = true, want false", rawURL)
}
}
for _, rawURL := range []string{
"https://open.feishu.cn/path",
"https://open.feishu.cn:443/path",
"https://OPEN.FEISHU.CN/path",
} {
candidate, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
if !IsPlatformEndpointURL(candidate) {
t.Errorf("IsPlatformEndpointURL(%q) = false, want true", rawURL)
}
}
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
var baseCodeMeta = map[int]CodeMeta{
// Copy Table domain errors (technical design chapter 18.2).
800020304: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
800010102: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition},
800080105: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
800040819: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict},
800070003: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
800100112: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown},
800100113: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown},
800040114: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
800070115: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
800010109: {Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument},
800030110: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound},
800070111: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
// Shared RPC errors used by Copy Table (technical design chapter 18.3).
800040802: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
800040803: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
800020812: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied},
800040832: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
800040817: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded},
800080821: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
800070831: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown},
}
func init() {
mergeCodeMeta(baseCodeMeta, "base")
}

View File

@@ -1,51 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMetaBaseTableCopyCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
retryable bool
}{
// Copy Table domain errors documented in chapter 18.2.
{code: 800020304, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied},
{code: 800010102, category: errs.CategoryValidation, subtype: errs.SubtypeFailedPrecondition},
{code: 800080105, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
{code: 800040819, category: errs.CategoryAPI, subtype: errs.SubtypeConflict},
{code: 800070003, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
{code: 800100112, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown},
{code: 800100113, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown},
{code: 800040114, category: errs.CategoryAPI, subtype: errs.SubtypeConflict, retryable: true},
{code: 800070115, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
{code: 800010109, category: errs.CategoryValidation, subtype: errs.SubtypeInvalidArgument},
{code: 800030110, category: errs.CategoryAPI, subtype: errs.SubtypeNotFound},
{code: 800070111, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
// Shared RPC errors used by Copy Table, documented in chapter 18.3.
{code: 800040802, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
{code: 800040803, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
{code: 800020812, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied},
{code: 800040832, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
{code: 800040817, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded},
{code: 800080821, category: errs.CategoryPolicy, subtype: errs.SubtypeAccessDenied},
{code: 800070831, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown},
}
for _, test := range tests {
t.Run(fmt.Sprint(test.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(test.code)
if !ok || meta.Category != test.category || meta.Subtype != test.subtype || meta.Retryable != test.retryable {
t.Fatalf("LookupCodeMeta(%d) = %#v, %v", test.code, meta, ok)
}
})
}
}

View File

@@ -23,7 +23,6 @@ type Stub struct {
RawBody []byte // raw bytes (takes precedence over Body when non-nil)
ContentType string // override Content-Type header (default: application/json)
Headers http.Header // optional full response headers (takes precedence over ContentType)
Error error // optional transport error returned after OnMatch
matched bool
// BodyFilter (optional): match only when the captured request body satisfies
@@ -94,9 +93,6 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
if matched.OnMatch != nil {
matched.OnMatch(req)
}
if matched.Error != nil {
return nil, matched.Error
}
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)

View File

@@ -4,7 +4,6 @@
package httpmock
import (
"errors"
"io"
"net/http"
"testing"
@@ -113,21 +112,3 @@ func TestRegistry_CustomStatus(t *testing.T) {
t.Errorf("want status 500, got %d", resp.StatusCode)
}
}
func TestRegistry_TransportError(t *testing.T) {
wantErr := errors.New("connection reset")
reg := &Registry{}
reg.Register(&Stub{
Method: "POST",
URL: "/transport-error",
Error: wantErr,
})
client := NewClient(reg)
req, _ := http.NewRequest("POST", "https://example.com/transport-error", nil)
_, err := client.Do(req)
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v, want transport error %v", err, wantErr)
}
reg.Verify(t)
}

View File

@@ -180,8 +180,8 @@ func saveCachedMerged(data []byte, cm CacheMeta) error {
// localVersion is sent as data_version query param for server-side version comparison.
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
// Remote metadata is platform traffic and must honor both the shared proxy
// configuration and the registered platform transport extension.
// Route through the shared proxy-plugin-aware transport so remote API
// definition fetches honor proxy plugin mode instead of bypassing it.
client := transport.NewHTTPClient(fetchTimeout)
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
if err != nil {

View File

@@ -12,8 +12,6 @@ import (
internaltransport "github.com/larksuite/cli/internal/transport"
)
var _ internaltransport.RoundTripperDecorator = (*Transport)(nil)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
@@ -42,28 +40,6 @@ func NewTransport(next http.RoundTripper, source Source) *Transport {
}
}
// BaseRoundTripper exposes the network transport so policy routers can clone
// and rebuild the complete decorator graph without dropping risk control.
func (t *Transport) BaseRoundTripper() http.RoundTripper {
if t == nil || t.next == nil {
return internaltransport.Fallback()
}
return t.next
}
// WithBaseRoundTripper returns an equivalent risk-control boundary over base.
func (t *Transport) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
if t == nil {
return NewTransport(base, nil)
}
cloned := *t
if base == nil {
base = internaltransport.Fallback()
}
cloned.next = base
return &cloned
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: MIT
// Package transport owns how the CLI assembles its outbound HTTP transport: the
// shared base RoundTripper (Shared/Fallback and the HTTP client constructors), the LARK_CLI_NO_PROXY
// shared base RoundTripper (Shared/Fallback/NewHTTPClient), the LARK_CLI_NO_PROXY
// direct-egress clone, and the ~/.lark-cli/proxy_config.json proxy-plugin mode.
//
// Proxy-plugin mode forces all outbound HTTP(S) requests through a fixed loopback

View File

@@ -1,258 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
"net/url"
"strings"
"sync"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
)
type requestMatcher func(*http.Request) bool
type transportPolicyBuilder func(http.RoundTripper) http.RoundTripper
type sdkBootstrapRedirectContextKey struct{}
var (
// larkws pins this client during package initialization.
sdkBootstrapHTTPClient = http.DefaultClient
installDefaultClientMu sync.Mutex
)
// sdkBootstrapTransport applies the platform HTTP policy only to dependency
// bootstrap requests selected by match. Unmatched DefaultClient traffic is
// delegated directly to the previous transport.
type sdkBootstrapTransport struct {
base http.RoundTripper
match requestMatcher
buildPlatformPolicy transportPolicyBuilder
policyMu sync.RWMutex
}
func (t *sdkBootstrapTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !t.isBootstrapRequest(req) {
return t.fallbackTransport().RoundTrip(req)
}
base := t.base
if base == nil {
// Resolve Shared lazily so bridge installation never initializes
// workspace-scoped proxy state ahead of workspace selection.
base = Shared()
}
buildPlatformPolicy := t.platformPolicyBuilder()
if buildPlatformPolicy == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"SDK bootstrap transport policy is not configured",
)
}
base = buildPlatformPolicy(base)
if base == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"SDK bootstrap transport policy returned a nil transport",
)
}
// Resolve extensions per hop so redirects retain platform policy.
extended := WrapWithExtensionForClass(base, exttransport.RequestClassPlatform)
guarded := &sameOriginRedirectTransport{base: extended}
return guarded.RoundTrip(req)
}
func (t *sdkBootstrapTransport) platformPolicyBuilder() transportPolicyBuilder {
t.policyMu.RLock()
defer t.policyMu.RUnlock()
return t.buildPlatformPolicy
}
func (t *sdkBootstrapTransport) setPlatformPolicyBuilder(build transportPolicyBuilder) {
t.policyMu.Lock()
t.buildPlatformPolicy = build
t.policyMu.Unlock()
}
func (t *sdkBootstrapTransport) isBootstrapRequest(req *http.Request) bool {
if req == nil {
return false
}
if _, redirected := req.Context().Value(sdkBootstrapRedirectContextKey{}).(struct{}); redirected {
return true
}
return t.match != nil && t.match(req)
}
func (t *sdkBootstrapTransport) fallbackTransport() http.RoundTripper {
if t.base != nil {
return t.base
}
// Preserve net/http's dynamic nil-Transport fallback.
return http.DefaultTransport
}
// sameOriginRedirectTransport rejects redirects before net/http can replay a
// bootstrap request to a different logical origin.
type sameOriginRedirectTransport struct {
base http.RoundTripper
}
func (t *sameOriginRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base.RoundTrip(req)
if err != nil || resp == nil || !isFollowedRedirect(resp.StatusCode) {
return resp, err
}
location := resp.Header.Get("Location")
if location == "" {
return resp, nil
}
target, parseErr := req.URL.Parse(location)
if parseErr != nil {
if resp.Body != nil {
_ = resp.Body.Close()
}
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"platform request returned an invalid redirect location: %v",
parseErr,
).WithCause(parseErr)
}
if sameOrigin(req.URL, target) {
return resp, nil
}
if resp.Body != nil {
_ = resp.Body.Close()
}
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"platform bootstrap blocked cross-origin redirect from %q to %q",
originName(req.URL),
originName(target),
)
}
// sdkBootstrapRedirectPolicy preserves the prior hook and marks each redirect hop.
func sdkBootstrapRedirectPolicy(
match requestMatcher,
previous func(*http.Request, []*http.Request) error,
) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
if previous != nil {
if err := previous(req, via); err != nil {
return err
}
} else if len(via) >= 10 {
// Retain net/http's default redirect limit.
return errs.NewNetworkError(
errs.SubtypeNetworkTransport,
"stopped after 10 redirects",
)
}
if req == nil || len(via) == 0 || match == nil || !match(via[0]) {
return nil
}
ctx := context.WithValue(req.Context(), sdkBootstrapRedirectContextKey{}, struct{}{})
*req = *req.WithContext(ctx)
return nil
}
}
func originName(candidate *url.URL) string {
if candidate == nil {
return ""
}
return strings.ToLower(candidate.Scheme) + "://" + candidate.Host
}
func sameOrigin(left, right *url.URL) bool {
if left == nil || right == nil {
return false
}
return strings.EqualFold(left.Scheme, right.Scheme) &&
strings.EqualFold(left.Hostname(), right.Hostname()) &&
originPort(left) == originPort(right)
}
func originPort(candidate *url.URL) string {
if port := candidate.Port(); port != "" {
return port
}
switch strings.ToLower(candidate.Scheme) {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func isFollowedRedirect(status int) bool {
switch status {
case http.StatusMovedPermanently,
http.StatusFound,
http.StatusSeeOther,
http.StatusTemporaryRedirect,
http.StatusPermanentRedirect:
return true
default:
return false
}
}
// InstallSDKTransportBridge wraps larkws's captured HTTP bootstrap client. All
// requests through that client hit the bridge, but only matched bootstrap
// traffic uses platform policy. The SDK owns the subsequent WebSocket dial,
// which does not use this net/http transport.
func InstallSDKTransportBridge(buildPlatformPolicy func(http.RoundTripper) http.RoundTripper) {
installDefaultClientMu.Lock()
defer installDefaultClientMu.Unlock()
installSDKTransportBridge(
sdkBootstrapHTTPClient,
isSDKWebSocketBootstrapRequest,
buildPlatformPolicy,
)
}
func isSDKWebSocketBootstrapRequest(req *http.Request) bool {
return req != nil &&
req.Method == http.MethodPost &&
core.IsPlatformEndpointURL(req.URL) &&
req.URL.Path == larkws.GenEndpointUri
}
func installSDKTransportBridge(
client *http.Client,
match requestMatcher,
buildPlatformPolicy transportPolicyBuilder,
) {
if client == nil {
return
}
if existing, ok := client.Transport.(*sdkBootstrapTransport); ok {
existing.setPlatformPolicyBuilder(buildPlatformPolicy)
return
}
base := client.Transport
previousRedirect := client.CheckRedirect
client.Transport = &sdkBootstrapTransport{
base: base,
match: match,
buildPlatformPolicy: buildPlatformPolicy,
}
client.CheckRedirect = sdkBootstrapRedirectPolicy(match, previousRedirect)
}

View File

@@ -1,120 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
exttransport "github.com/larksuite/cli/extension/transport"
)
var _ RoundTripperDecorator = (*ExtensionMiddleware)(nil)
type resolvedExtension struct {
provider exttransport.Provider
interceptor exttransport.Interceptor
}
func resolveExtension() *resolvedExtension {
p := exttransport.GetProvider()
if p == nil {
return nil
}
interceptor := p.ResolveInterceptor(context.Background())
if interceptor == nil {
return nil
}
return &resolvedExtension{provider: p, interceptor: interceptor}
}
func (e *resolvedExtension) wrap(base http.RoundTripper, class exttransport.RequestClass, enforceScope bool) http.RoundTripper {
if base == nil {
base = Shared()
}
if e == nil {
return base
}
if enforceScope {
if scoped, ok := e.provider.(exttransport.ScopedProvider); ok && !scoped.SupportsRequestClass(class) {
return base
}
}
return &ExtensionMiddleware{Base: base, Ext: e.interceptor, ExtName: e.provider.Name()}
}
// ExtensionMiddleware wraps the built-in transport chain with extension
// pre/post hooks. The built-in chain always executes unless an
// exttransport.AbortableInterceptor rejects the request.
//
// The original request context is restored after the pre hook to prevent an
// extension from replacing cancellation, deadlines, or built-in values. The
// request is cloned so URL and header mutations do not alter the caller's
// request object. The body remains shared; interceptors that consume it must
// restore it before returning.
type ExtensionMiddleware struct {
Base http.RoundTripper
Ext exttransport.Interceptor
ExtName string
}
// BaseRoundTripper returns the wrapped built-in transport chain.
func (m *ExtensionMiddleware) BaseRoundTripper() http.RoundTripper {
if m.Base == nil {
return Shared()
}
return m.Base
}
// WithBaseRoundTripper clones the middleware over base.
func (m *ExtensionMiddleware) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *m
cloned.Base = base
return &cloned
}
// RoundTrip invokes the extension pre hook, the wrapped transport, and then
// the optional post hook. Abortable interceptors can stop the request before
// the wrapped transport is called.
func (m *ExtensionMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
origCtx := req.Context()
req = req.Clone(origCtx)
var (
post func(*http.Response, error)
abortErr error
)
if a, ok := m.Ext.(exttransport.AbortableInterceptor); ok {
post, abortErr = a.PreRoundTripE(req)
} else {
post = m.Ext.PreRoundTrip(req)
}
if abortErr != nil {
if post != nil {
post(nil, abortErr)
}
return nil, &exttransport.AbortError{Extension: m.ExtName, Reason: abortErr}
}
req = req.WithContext(origCtx)
resp, err := m.BaseRoundTripper().RoundTrip(req)
if post != nil {
post(resp, err)
}
return resp, err
}
// WrapWithExtension wraps base with the currently registered transport
// extension. With no registered provider or no resolved interceptor, base is
// returned unchanged.
func WrapWithExtension(base http.RoundTripper) http.RoundTripper {
return resolveExtension().wrap(base, "", false)
}
// WrapWithExtensionForClass wraps base only when the registered provider
// supports class. Providers without the optional ScopedProvider interface keep
// their historical all-request behavior.
func WrapWithExtensionForClass(base http.RoundTripper, class exttransport.RequestClass) http.RoundTripper {
return resolveExtension().wrap(base, class, true)
}

View File

@@ -1,924 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"sync/atomic"
"testing"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
)
type testProvider struct {
interceptor exttransport.Interceptor
resolveCalls *int
}
func (p testProvider) Name() string { return "test-provider" }
func (p testProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
if p.resolveCalls != nil {
*p.resolveCalls++
}
return p.interceptor
}
type scopedTestProvider struct {
testProvider
supported exttransport.RequestClass
}
func (p scopedTestProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == p.supported
}
type testHeaderInterceptor struct {
calls int
}
func (i *testHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-Test-Platform", "routed")
return nil
}
type abortingTestInterceptor struct {
reason error
post func(*http.Response, error)
}
func (i *abortingTestInterceptor) PreRoundTrip(*http.Request) func(*http.Response, error) {
panic("PreRoundTrip called for abortable interceptor")
}
func (i *abortingTestInterceptor) PreRoundTripE(*http.Request) (func(*http.Response, error), error) {
return i.post, i.reason
}
func TestLegacyProviderKeepsAllRequestBehavior(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
interceptor := &testHeaderInterceptor{}
previousProvider := exttransport.GetProvider()
exttransport.Register(testProvider{interceptor: interceptor})
t.Cleanup(func() { exttransport.Register(previousProvider) })
received := make(chan string, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Get("X-Test-Platform")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
for _, client := range []*http.Client{
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
NewExternalHTTPClient(0),
} {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
if got := <-received; got != "routed" {
t.Fatalf("platform request header = %q, want routed", got)
}
if got := <-received; got != "routed" {
t.Fatalf("external request header = %q, want routed for legacy provider", got)
}
if interceptor.calls != 2 {
t.Fatalf("extension calls = %d, want exactly 2", interceptor.calls)
}
}
func TestScopedProviderOnlyRunsForSupportedRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
interceptor := &testHeaderInterceptor{}
previousProvider := exttransport.GetProvider()
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
received := make(chan string, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
received <- req.Header.Get("X-Test-Platform")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
clients := []*http.Client{
ClientForRequestClass(NewHTTPClient(0), exttransport.RequestClassPlatform),
NewExternalHTTPClient(0),
}
for _, client := range clients {
resp, err := client.Get(server.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
if got := <-received; got != "routed" {
t.Fatalf("platform request header = %q, want routed", got)
}
if got := <-received; got != "" {
t.Fatalf("external request received scoped provider header %q", got)
}
if interceptor.calls != 1 {
t.Fatalf("extension calls = %d, want exactly 1", interceptor.calls)
}
}
func TestHTTPPolicyRouterResolvesProviderOnce(t *testing.T) {
resolveCalls := 0
previousProvider := exttransport.GetProvider()
exttransport.Register(testProvider{
interceptor: &testHeaderInterceptor{},
resolveCalls: &resolveCalls,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
_ = NewHTTPPolicyRouter(base, base)
if resolveCalls != 1 {
t.Fatalf("ResolveInterceptor() calls = %d, want 1 per router", resolveCalls)
}
}
func TestSDKBootstrapBridgeBlocksCrossOriginRedirectAfterSameOriginHop(t *testing.T) {
var externalCalls atomic.Int32
var relayBody string
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "external.example" {
externalCalls.Add(1)
return noContentResponse(req), nil
}
switch req.URL.Path {
case "/bootstrap":
return redirectResponse(req, http.StatusTemporaryRedirect, "/relay"), nil
case "/relay":
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
relayBody = string(body)
return redirectResponse(
req,
http.StatusPermanentRedirect,
"https://external.example/target",
), nil
default:
return noContentResponse(req), nil
}
})
client := &http.Client{Transport: base}
installSDKTransportBridge(client, func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
}, identityTransportPolicy)
const secret = "app_secret=secret"
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader(secret),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Do() error = %v, want cross-origin redirect rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if relayBody != secret {
t.Fatalf("same-origin relay body = %q, want %q", relayBody, secret)
}
if got := externalCalls.Load(); got != 0 {
t.Fatalf("cross-origin target calls = %d, want 0", got)
}
}
func TestSDKBootstrapRedirectGuardClassifiesInvalidLocation(t *testing.T) {
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
return redirectResponse(req, http.StatusFound, "%"), nil
})
client := &http.Client{Transport: &sameOriginRedirectTransport{base: base}}
resp, err := client.Get("https://platform.example/bootstrap")
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "invalid redirect location") {
t.Fatalf("Do() error = %v, want invalid redirect rejection", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Do() problem = %#v, %v; want internal/invalid_response", problem, ok)
}
}
type redirectPolicyInterceptor struct {
calls int
}
func (i *redirectPolicyInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-Extension-Hop", strconv.Itoa(i.calls))
req.Header.Set("X-Reserved", "extension")
return nil
}
func TestSDKBootstrapBridgeRetainsPoliciesAcrossSameOriginRedirect(t *testing.T) {
previousProvider := exttransport.GetProvider()
interceptor := &redirectPolicyInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
var finalHeaders http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/bootstrap":
return redirectResponse(req, http.StatusTemporaryRedirect, "/next"), nil
case "/next":
finalHeaders = req.Header.Clone()
return noContentResponse(req), nil
default:
return noContentResponse(req), nil
}
})
builtInCalls := 0
client := &http.Client{Transport: base}
installSDKTransportBridge(
client,
func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
},
func(base http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
builtInCalls++
req = req.Clone(req.Context())
req.Header.Set("X-Builtin-Hop", strconv.Itoa(builtInCalls))
req.Header.Set("X-Reserved", "trusted")
return base.RoundTrip(req)
})
},
)
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader("body"),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if finalHeaders == nil {
t.Fatal("same-origin redirect target was not called")
}
if interceptor.calls != 2 {
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
}
if builtInCalls != 2 {
t.Fatalf("built-in policy calls = %d, want 2", builtInCalls)
}
if got := finalHeaders.Get("X-Extension-Hop"); got != "2" {
t.Fatalf("final X-Extension-Hop = %q, want 2", got)
}
if got := finalHeaders.Get("X-Builtin-Hop"); got != "2" {
t.Fatalf("final X-Builtin-Hop = %q, want 2", got)
}
if got := finalHeaders.Get("X-Reserved"); got != "trusted" {
t.Fatalf("final X-Reserved = %q, want trusted built-in value", got)
}
}
type redirectRewriteInterceptor struct {
target *url.URL
postLocation string
calls int
}
func (i *redirectRewriteInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.URL.Scheme = i.target.Scheme
req.URL.Host = i.target.Host
if i.postLocation == "" {
return nil
}
return func(resp *http.Response, err error) {
if err == nil && resp != nil && isFollowedRedirect(resp.StatusCode) {
resp.Header.Set("Location", i.postLocation)
}
}
}
func TestSDKBootstrapRedirectGuardUsesLogicalURLAfterExtensionRewrite(t *testing.T) {
sidecarURL, err := url.Parse("https://sidecar.example")
if err != nil {
t.Fatal(err)
}
sidecarCalls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host != sidecarURL.Host {
t.Fatalf("network host = %q, want extension target %q", req.URL.Host, sidecarURL.Host)
}
sidecarCalls++
switch req.URL.Path {
case "/bootstrap":
return redirectResponse(
req,
http.StatusTemporaryRedirect,
"https://platform.example/next",
), nil
case "/next":
return noContentResponse(req), nil
default:
return noContentResponse(req), nil
}
})
previousProvider := exttransport.GetProvider()
interceptor := &redirectRewriteInterceptor{target: sidecarURL}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
client := &http.Client{Transport: base}
installSDKTransportBridge(client, func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
}, identityTransportPolicy)
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader("body"),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if sidecarCalls != 2 {
t.Fatalf("sidecar calls = %d, want 2", sidecarCalls)
}
if interceptor.calls != 2 {
t.Fatalf("extension calls = %d, want 2", interceptor.calls)
}
}
func TestSDKBootstrapRedirectGuardChecksLocationAfterExtensionPostHook(t *testing.T) {
var externalCalls atomic.Int32
sidecarURL, err := url.Parse("https://sidecar.example")
if err != nil {
t.Fatal(err)
}
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host == "external.example" {
externalCalls.Add(1)
return noContentResponse(req), nil
}
return redirectResponse(
req,
http.StatusTemporaryRedirect,
"https://platform.example/next",
), nil
})
previousProvider := exttransport.GetProvider()
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: &redirectRewriteInterceptor{
target: sidecarURL,
postLocation: "https://external.example/target",
}},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
client := &http.Client{Transport: base}
installSDKTransportBridge(client, func(req *http.Request) bool {
return req.URL != nil && req.URL.Path == "/bootstrap"
}, identityTransportPolicy)
req, err := http.NewRequest(
http.MethodPost,
"https://platform.example/bootstrap",
strings.NewReader("secret"),
)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "cross-origin redirect") {
t.Fatalf("Do() error = %v, want post-hook Location rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("Do() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if got := externalCalls.Load(); got != 0 {
t.Fatalf("post-hook redirect target calls = %d, want 0", got)
}
}
func TestSameOriginNormalizesDefaultPort(t *testing.T) {
left, err := url.Parse("https://platform.example/bootstrap")
if err != nil {
t.Fatal(err)
}
right, err := url.Parse("https://platform.example:443/next")
if err != nil {
t.Fatal(err)
}
if !sameOrigin(left, right) {
t.Fatal("sameOrigin() = false for equivalent default HTTPS ports")
}
}
func TestDefaultClientBridgeCoversWebSocketSDKBootstrap(t *testing.T) {
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
previousProvider := exttransport.GetProvider()
interceptor := &testHeaderInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
seenHeader := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
seenHeader <- req.Header.Get("X-Test-Platform")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"code":400,"msg":"stop after bootstrap"}`)
}))
t.Cleanup(server.Close)
installSDKTransportBridge(sdkBootstrapHTTPClient, func(req *http.Request) bool {
return req.URL != nil && req.URL.Host == strings.TrimPrefix(server.URL, "http://")
}, identityTransportPolicy)
client := larkws.NewClient(
"test-app",
"test-secret",
larkws.WithDomain(server.URL),
larkws.WithAutoReconnect(false),
)
if err := client.Start(context.Background()); err == nil {
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
}
if got := <-seenHeader; got != "routed" {
t.Fatalf("WebSocket bootstrap header = %q, want routed", got)
}
if interceptor.calls != 1 {
t.Fatalf("extension calls = %d, want exactly 1 bootstrap call", interceptor.calls)
}
}
func TestSDKTransportBridgeUsesPinnedClientAfterGlobalReplacement(t *testing.T) {
preserveHTTPClientState(t, sdkBootstrapHTTPClient)
oldDefaultClient := http.DefaultClient
t.Cleanup(func() { http.DefaultClient = oldDefaultClient })
var pinnedCalls atomic.Int32
pinnedHeader := make(chan string, 1)
sdkBootstrapHTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
pinnedCalls.Add(1)
pinnedHeader <- req.Header.Get("X-Pinned-Bridge")
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"code":400,"msg":"stop"}`)),
Request: req,
}, nil
})
sdkBootstrapHTTPClient.CheckRedirect = nil
var replacementCalls atomic.Int32
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
replacementCalls.Add(1)
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: http.NoBody,
Request: req,
}, nil
})}
InstallSDKTransportBridge(func(base http.RoundTripper) http.RoundTripper {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("X-Pinned-Bridge", "routed")
return base.RoundTrip(req)
})
})
client := larkws.NewClient(
"test-app",
"test-secret",
larkws.WithAutoReconnect(false),
)
if err := client.Start(context.Background()); err == nil {
t.Fatal("WebSocket SDK Start() error = nil, want bootstrap failure")
}
if got := pinnedCalls.Load(); got != 1 {
t.Fatalf("SDK-pinned client calls = %d, want 1", got)
}
if got := <-pinnedHeader; got != "routed" {
t.Fatalf("SDK-pinned bridge header = %q, want routed", got)
}
if got := replacementCalls.Load(); got != 0 {
t.Fatalf("replacement DefaultClient calls = %d, want 0", got)
}
}
func TestSDKWebSocketBootstrapMatcherIsNarrow(t *testing.T) {
tests := []struct {
name string
method string
url string
want bool
}{
{
name: "platform bootstrap",
method: http.MethodPost,
url: "https://open.feishu.cn/callback/ws/endpoint",
want: true,
},
{
name: "other platform path",
method: http.MethodPost,
url: "https://open.feishu.cn/open-apis/test",
},
{
name: "wrong bootstrap method",
method: http.MethodGet,
url: "https://open.feishu.cn/callback/ws/endpoint",
},
{
name: "external lookalike",
method: http.MethodPost,
url: "https://external.example/callback/ws/endpoint",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(tt.method, tt.url, nil)
if err != nil {
t.Fatal(err)
}
if got := isSDKWebSocketBootstrapRequest(req); got != tt.want {
t.Fatalf("isSDKWebSocketBootstrapRequest() = %v, want %v", got, tt.want)
}
})
}
}
func TestSDKTransportBridgeLeavesOtherPlatformPathsUntouched(t *testing.T) {
previousProvider := exttransport.GetProvider()
interceptor := &testHeaderInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(previousProvider) })
baseCalls := 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
baseCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})}
installSDKTransportBridge(client, isSDKWebSocketBootstrapRequest, nil)
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if baseCalls != 1 {
t.Fatalf("base calls = %d, want 1", baseCalls)
}
if interceptor.calls != 0 {
t.Fatalf("extension calls = %d, want 0 for unmatched DefaultClient traffic", interceptor.calls)
}
}
func TestSDKTransportBridgeNilBasePreservesDefaultTransportForUnmatchedRequest(t *testing.T) {
oldDefaultTransport := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = oldDefaultTransport })
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "1")
var firstCalls atomic.Int32
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
firstCalls.Add(1)
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
client := &http.Client{}
installSDKTransportBridge(client, func(*http.Request) bool { return false }, nil)
var currentCalls atomic.Int32
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
currentCalls.Add(1)
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
})
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:1/unmatched", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := firstCalls.Load(); got != 0 {
t.Fatalf("install-time DefaultTransport calls = %d, want 0", got)
}
if got := currentCalls.Load(); got != 1 {
t.Fatalf("request-time DefaultTransport calls = %d, want 1", got)
}
}
func TestSDKTransportBridgeUpdatesPlatformPolicy(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return noContentResponse(req), nil
})}
var firstCalls, secondCalls int
build := func(calls *int) transportPolicyBuilder {
return func(base http.RoundTripper) http.RoundTripper {
*calls++
return base
}
}
match := func(*http.Request) bool { return true }
installSDKTransportBridge(client, match, build(&firstCalls))
installSDKTransportBridge(client, match, build(&secondCalls))
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if firstCalls != 0 || secondCalls != 1 {
t.Fatalf("policy calls = (%d, %d), want (0, 1)", firstCalls, secondCalls)
}
}
func TestSDKBootstrapTransportFailsClosedWithoutPlatformPolicy(t *testing.T) {
var baseCalls atomic.Int32
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
baseCalls.Add(1)
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})}
installSDKTransportBridge(client, func(*http.Request) bool { return true }, nil)
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "policy is not configured") {
t.Fatalf("Do() error = %v, want missing policy rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryInternal ||
problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
}
if got := baseCalls.Load(); got != 0 {
t.Fatalf("base transport calls = %d, want 0", got)
}
}
func TestSDKBootstrapTransportFailsClosedForNilPlatformTransport(t *testing.T) {
var baseCalls atomic.Int32
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
baseCalls.Add(1)
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})}
installSDKTransportBridge(client, func(*http.Request) bool { return true }, func(http.RoundTripper) http.RoundTripper {
return nil
})
req, err := http.NewRequest(http.MethodPost, "https://platform.example/bootstrap", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil || !strings.Contains(err.Error(), "nil transport") {
t.Fatalf("Do() error = %v, want nil policy transport rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryInternal ||
problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("Do() problem = %#v, %v; want internal/unknown", problem, ok)
}
if got := baseCalls.Load(); got != 0 {
t.Fatalf("base transport calls = %d, want 0", got)
}
}
func TestSDKBootstrapRedirectPolicyRetainsDefaultLimit(t *testing.T) {
policy := sdkBootstrapRedirectPolicy(nil, nil)
via := make([]*http.Request, 10)
err := policy(&http.Request{}, via)
if err == nil {
t.Fatal("redirect policy error = nil after 10 redirects")
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryNetwork ||
problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("redirect problem = %#v, %v; want network/transport", problem, ok)
}
}
func TestExtensionMiddlewareUsesFallbackWhenBaseIsNil(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
previous := http.DefaultTransport
var calls atomic.Int32
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previous })
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := (&ExtensionMiddleware{Ext: &testHeaderInterceptor{}}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := calls.Load(); got != 1 {
t.Fatalf("fallback transport calls = %d, want 1", got)
}
}
func TestExtensionMiddlewareAbortsBeforeBase(t *testing.T) {
reason := errors.New("blocked")
baseCalled := false
postCalled := false
interceptor := &abortingTestInterceptor{
reason: reason,
post: func(resp *http.Response, err error) {
postCalled = true
if resp != nil || err != reason {
t.Errorf("post arguments = (%v, %v), want (nil, reason)", resp, err)
}
},
}
middleware := &ExtensionMiddleware{
Base: roundTripFunc(func(*http.Request) (*http.Response, error) {
baseCalled = true
return nil, nil
}),
Ext: interceptor,
ExtName: "test-provider",
}
resp, err := middleware.RoundTrip(httptest.NewRequest(http.MethodGet, "https://example.com", nil))
if resp != nil {
t.Fatalf("response = %v, want nil", resp)
}
var abortErr *exttransport.AbortError
if !errors.As(err, &abortErr) {
t.Fatalf("error = %T, want *transport.AbortError", err)
}
if abortErr.Extension != "test-provider" || abortErr.Reason != reason {
t.Fatalf("abort error = %#v, want provider and reason", abortErr)
}
if baseCalled {
t.Fatal("base transport was called")
}
if !postCalled {
t.Fatal("post hook was not called")
}
}
func preserveHTTPClientState(t *testing.T, client *http.Client) {
t.Helper()
oldTransport := client.Transport
oldCheckRedirect := client.CheckRedirect
t.Cleanup(func() {
client.Transport = oldTransport
client.CheckRedirect = oldCheckRedirect
})
}
func identityTransportPolicy(base http.RoundTripper) http.RoundTripper {
return base
}
func redirectResponse(req *http.Request, status int, location string) *http.Response {
return &http.Response{
StatusCode: status,
Header: http.Header{"Location": []string{location}},
Body: http.NoBody,
Request: req,
}
}
func noContentResponse(req *http.Request) *http.Response {
return &http.Response{
StatusCode: http.StatusNoContent,
Body: http.NoBody,
Request: req,
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

View File

@@ -1,232 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"context"
"net/http"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
"github.com/larksuite/cli/internal/core"
)
type requestClassContextKey struct{}
type forcedRequestClassContextKey struct{}
// HTTPPolicyRouter selects an HTTP transport policy from request intent and
// the endpoint catalog. Explicit request intent takes precedence; otherwise
// known platform endpoints use the platform policy and all other URLs use the
// external policy.
type HTTPPolicyRouter struct {
platform http.RoundTripper
external http.RoundTripper
}
// RoundTripperDecorator describes a transport layer that can be rebuilt over
// a cloned base transport. Connection-policy helpers use this contract to
// preserve retry, response, and extension layers while safely customizing the
// innermost *http.Transport.
type RoundTripperDecorator interface {
BaseRoundTripper() http.RoundTripper
WithBaseRoundTripper(http.RoundTripper) http.RoundTripper
}
// NewHTTPPolicyRouter constructs a router over two policy chains. A nil chain
// falls back to the shared proxy-aware transport. The currently registered
// extension provider is resolved once and applied according to its optional
// ScopedProvider contract.
func NewHTTPPolicyRouter(platform, external http.RoundTripper) *HTTPPolicyRouter {
if platform == nil {
platform = Shared()
}
if external == nil {
external = Shared()
}
extension := resolveExtension()
return &HTTPPolicyRouter{
platform: extension.wrap(platform, exttransport.RequestClassPlatform, true),
external: extension.wrap(external, exttransport.RequestClassExternal, true),
}
}
// RoundTrip dispatches the request to its selected policy chain.
func (r *HTTPPolicyRouter) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"HTTP policy router received a nil request",
)
}
class, err := classifyRequest(req)
if err != nil {
return nil, err
}
if class == exttransport.RequestClassPlatform {
return r.platform.RoundTrip(req)
}
return r.external.RoundTrip(req)
}
func (r *HTTPPolicyRouter) transportForClass(class exttransport.RequestClass) (http.RoundTripper, bool) {
switch class {
case exttransport.RequestClassPlatform:
return r.platform, true
case exttransport.RequestClassExternal:
return r.external, true
default:
return nil, false
}
}
func classifyRequest(req *http.Request) (exttransport.RequestClass, error) {
if explicit, ok := req.Context().Value(requestClassContextKey{}).(exttransport.RequestClass); ok {
switch explicit {
case exttransport.RequestClassPlatform, exttransport.RequestClassExternal:
return explicit, nil
default:
return "", errs.NewInternalError(
errs.SubtypeUnknown,
"unsupported HTTP request class %q",
explicit,
)
}
}
if core.IsPlatformEndpointURL(req.URL) {
return exttransport.RequestClassPlatform, nil
}
return exttransport.RequestClassExternal, nil
}
// WithRequestClass returns a shallow copy of req with explicit routing intent.
func WithRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
if req == nil {
return nil
}
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
return req.WithContext(ctx)
}
func withForcedRequestClass(req *http.Request, class exttransport.RequestClass) *http.Request {
if req == nil {
return nil
}
if _, forced := req.Context().Value(forcedRequestClassContextKey{}).(struct{}); forced {
return req
}
ctx := context.WithValue(req.Context(), requestClassContextKey{}, class)
ctx = context.WithValue(ctx, forcedRequestClassContextKey{}, struct{}{})
return req.WithContext(ctx)
}
type requestClassTransport struct {
base http.RoundTripper
class exttransport.RequestClass
}
func (t *requestClassTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return t.base.RoundTrip(withForcedRequestClass(req, t.class))
}
// CloneHTTPTransport exposes a structural cloning capability without requiring
// higher-level safety helpers to import this package. The explicit request
// class selects the policy branch that must be rebuilt.
func (t *requestClassTransport) CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool) {
return CloneHTTPTransportForRequestClass(t.base, t.class)
}
// TransformHTTPTransport clones the selected policy branch and replaces its
// concrete transport in place. Keeping the replacement at the graph leaf is
// important for policies that must observe requests after outer decorators
// have run, such as proxy selection.
func (t *requestClassTransport) TransformHTTPTransport(transform func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool) {
return transformHTTPTransportForRequestClass(t.base, t.class, transform, 0)
}
// ClientForRequestClass clones client and forces all of its requests through a
// specific policy class. The original client is never mutated.
func ClientForRequestClass(client *http.Client, class exttransport.RequestClass) *http.Client {
if client == nil {
client = &http.Client{}
}
cloned := *client
base := client.Transport
if base == nil {
base = Shared()
}
cloned.Transport = &requestClassTransport{base: base, class: class}
return &cloned
}
// CloneHTTPTransportForRequestClass selects one policy branch, clones its
// innermost *http.Transport, and rebuilds every composable decorator around
// the clone. Callers can customize concrete before using rebuilt. The original
// transport graph is never mutated.
func CloneHTTPTransportForRequestClass(base http.RoundTripper, class exttransport.RequestClass) (rebuilt http.RoundTripper, concrete *http.Transport, ok bool) {
rebuilt, ok = transformHTTPTransportForRequestClass(base, class, func(cloned *http.Transport) (http.RoundTripper, bool) {
concrete = cloned
return cloned, true
}, 0)
if !ok {
return nil, nil, false
}
return rebuilt, concrete, true
}
func transformHTTPTransportForRequestClass(
base http.RoundTripper,
class exttransport.RequestClass,
transform func(*http.Transport) (http.RoundTripper, bool),
depth int,
) (http.RoundTripper, bool) {
if depth > 32 {
return nil, false
}
if base == nil || transform == nil {
if transform == nil {
return nil, false
}
base = Shared()
}
switch current := base.(type) {
case *http.Transport:
cloned := cloneHTTPTransport(current)
rebuilt, valid := transform(cloned)
return rebuilt, valid && rebuilt != nil
case *requestClassTransport:
return transformHTTPTransportForRequestClass(current.base, class, transform, depth+1)
case *HTTPPolicyRouter:
selected, valid := current.transportForClass(class)
if !valid {
return nil, false
}
return transformHTTPTransportForRequestClass(selected, class, transform, depth+1)
case RoundTripperDecorator:
inner := current.BaseRoundTripper()
if inner == nil || inner == base {
return nil, false
}
rebuiltInner, valid := transformHTTPTransportForRequestClass(inner, class, transform, depth+1)
if !valid {
return nil, false
}
rebuilt := current.WithBaseRoundTripper(rebuiltInner)
return rebuilt, rebuilt != nil
default:
return nil, false
}
}
func cloneHTTPTransport(source *http.Transport) *http.Transport {
cloned := source.Clone()
// Clone leaves an auto-configured h2 handler on source.
if cloned.TLSNextProto == nil {
if _, ok := source.TLSNextProto["h2"]; ok {
cloned.ForceAttemptHTTP2 = true
}
}
return cloned
}

View File

@@ -1,351 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package transport
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
)
type cloneTestDecorator struct {
base http.RoundTripper
}
func (d *cloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
return d.base.RoundTrip(req)
}
func (d *cloneTestDecorator) BaseRoundTripper() http.RoundTripper {
return d.base
}
func (d *cloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
return &cloneTestDecorator{base: base}
}
type headerCloneTestDecorator struct {
base http.RoundTripper
}
func (d *headerCloneTestDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("X-Decorator", "applied")
return d.base.RoundTrip(req)
}
func (d *headerCloneTestDecorator) BaseRoundTripper() http.RoundTripper {
return d.base
}
func (d *headerCloneTestDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
return &headerCloneTestDecorator{base: base}
}
func TestHTTPPolicyRouterClassifiesFromEndpointCatalog(t *testing.T) {
exttransport.Register(nil)
platformCalls := 0
externalCalls := 0
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
platformCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
roundTripFunc(func(req *http.Request) (*http.Response, error) {
externalCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
)
for _, rawURL := range []string{
"https://open.feishu.cn/open-apis/test",
"https://example.com/file",
} {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := router.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
if platformCalls != 1 || externalCalls != 1 {
t.Fatalf("platform calls = %d, external calls = %d; want 1 each", platformCalls, externalCalls)
}
}
func TestHTTPPolicyRouterExplicitClassOverridesCatalog(t *testing.T) {
exttransport.Register(nil)
platformCalls := 0
externalCalls := 0
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
platformCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
roundTripFunc(func(req *http.Request) (*http.Response, error) {
externalCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
)
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req = WithRequestClass(req, exttransport.RequestClassExternal)
resp, err := router.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if platformCalls != 0 || externalCalls != 1 {
t.Fatalf("platform calls = %d, external calls = %d; want 0 and 1", platformCalls, externalCalls)
}
}
func TestClientForRequestClassOutermostIntentWins(t *testing.T) {
exttransport.Register(nil)
platformCalls := 0
externalCalls := 0
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
platformCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
roundTripFunc(func(req *http.Request) (*http.Response, error) {
externalCalls++
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}),
)
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
resp, err := external.Get("https://open.feishu.cn/open-apis/test")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if platformCalls != 0 || externalCalls != 1 {
t.Fatalf("platform calls = %d, external calls = %d; want outer external intent to win", platformCalls, externalCalls)
}
}
func TestHTTPPolicyRouterRejectsInvalidExplicitClass(t *testing.T) {
exttransport.Register(nil)
router := NewHTTPPolicyRouter(nil, nil)
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
if err != nil {
t.Fatal(err)
}
req = WithRequestClass(req, exttransport.RequestClass("invalid"))
if _, err := router.RoundTrip(req); err == nil || !strings.Contains(err.Error(), "unsupported HTTP request class") {
t.Fatalf("RoundTrip() error = %v, want unsupported request class", err)
} else if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryInternal ||
problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
}
func TestHTTPPolicyRouterRejectsNilRequest(t *testing.T) {
router := NewHTTPPolicyRouter(nil, nil)
_, err := router.RoundTrip(nil)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
}
func TestHTTPPolicyRouterReclassifiesRedirectTargets(t *testing.T) {
interceptor := &testHeaderInterceptor{}
exttransport.Register(scopedTestProvider{
testProvider: testProvider{interceptor: interceptor},
supported: exttransport.RequestClassPlatform,
})
t.Cleanup(func() { exttransport.Register(nil) })
receivedHeader := make(chan string, 1)
external := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
receivedHeader <- req.Header.Get("X-Test-Platform")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(external.Close)
router := NewHTTPPolicyRouter(
roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{external.URL}},
Body: http.NoBody,
Request: req,
}, nil
}),
http.DefaultTransport,
)
client := &http.Client{Transport: router}
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/start", nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if got := <-receivedHeader; got != "" {
t.Fatalf("redirect target received platform-scoped header %q", got)
}
if interceptor.calls != 1 {
t.Fatalf("extension calls = %d, want only the initial platform request", interceptor.calls)
}
}
func TestCloneHTTPTransportForRequestClassRebuildsDecorators(t *testing.T) {
wantErr := errors.New("preserved proxy policy")
base := &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) {
return nil, wantErr
},
}
decorated := &cloneTestDecorator{base: base}
router := NewHTTPPolicyRouter(decorated, decorated)
rebuilt, concrete, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
if !ok {
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
}
if concrete == base {
t.Fatal("CloneHTTPTransportForRequestClass() reused the original *http.Transport")
}
if _, ok := rebuilt.(*cloneTestDecorator); !ok {
t.Fatalf("rebuilt transport type = %T, want *cloneTestDecorator", rebuilt)
}
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, wantErr) {
t.Fatalf("RoundTrip() error = %v, want %v", err, wantErr)
}
}
func TestCloneHTTPTransportForRequestClassPreservesAutomaticHTTP2(t *testing.T) {
previousProvider := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previousProvider) })
source := &http.Transport{
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
}
router := NewHTTPPolicyRouter(&http.Transport{}, source)
_, cloned, ok := CloneHTTPTransportForRequestClass(router, exttransport.RequestClassExternal)
if !ok {
t.Fatal("CloneHTTPTransportForRequestClass() ok = false")
}
if !cloned.ForceAttemptHTTP2 {
t.Fatal("ForceAttemptHTTP2 = false, want true")
}
if cloned.TLSNextProto != nil {
t.Fatal("TLSNextProto is non-nil, want automatic HTTP/2")
}
}
func TestCloneHTTPTransportForRequestClassKeepsOutermostIntent(t *testing.T) {
platformErr := errors.New("platform transport")
externalErr := errors.New("external transport")
newBlocked := func(reason error) *http.Transport {
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
}
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
platform := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassPlatform)
external := ClientForRequestClass(platform, exttransport.RequestClassExternal)
source, ok := external.Transport.(interface {
CloneHTTPTransport() (http.RoundTripper, *http.Transport, bool)
})
if !ok {
t.Fatalf("transport type %T has no clone capability", external.Transport)
}
rebuilt, _, ok := source.CloneHTTPTransport()
if !ok {
t.Fatal("CloneHTTPTransport() ok = false")
}
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
if _, err := rebuilt.RoundTrip(req); !errors.Is(err, externalErr) {
t.Fatalf("RoundTrip() error = %v, want outer external transport error %v", err, externalErr)
}
}
func TestClientForRequestClassOverridesCallerIntent(t *testing.T) {
platformErr := errors.New("platform transport")
externalErr := errors.New("external transport")
newBlocked := func(reason error) *http.Transport {
return &http.Transport{Proxy: func(*http.Request) (*url.URL, error) { return nil, reason }}
}
router := NewHTTPPolicyRouter(newBlocked(platformErr), newBlocked(externalErr))
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
req = WithRequestClass(req, exttransport.RequestClassPlatform)
if _, err := client.Do(req); !errors.Is(err, externalErr) {
t.Fatalf("Do() error = %v, want forced external transport error %v", err, externalErr)
}
}
func TestTransformHTTPTransportReplacesLeafInsideDecorators(t *testing.T) {
exttransport.Register(nil)
decorated := &headerCloneTestDecorator{base: &http.Transport{}}
router := NewHTTPPolicyRouter(decorated, decorated)
client := ClientForRequestClass(&http.Client{Transport: router}, exttransport.RequestClassExternal)
source, ok := client.Transport.(interface {
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
})
if !ok {
t.Fatalf("transport type %T has no transform capability", client.Transport)
}
rebuilt, ok := source.TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool) {
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
if got := req.Header.Get("X-Decorator"); got != "applied" {
t.Fatalf("leaf received X-Decorator = %q, want applied", got)
}
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}), true
})
if !ok {
t.Fatal("TransformHTTPTransport() ok = false")
}
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := rebuilt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}

View File

@@ -8,8 +8,6 @@ import (
"os"
"sync"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
)
// Shared returns the base http.RoundTripper for all CLI HTTP clients.
@@ -57,29 +55,21 @@ func Fallback() *http.Transport {
return noProxyTransport()
}
// NewHTTPClient returns a policy-routed client over the shared proxy-aware
// transport. Known platform endpoints use the platform request class; all
// other URLs use the external request class. Existing unscoped transport
// providers continue to apply to both classes.
// NewHTTPClient returns an *http.Client whose Transport is the shared,
// proxy-plugin-aware base (see Shared). Prefer this over a bare &http.Client{}
// for outbound requests: a bare client falls back to http.DefaultTransport and
// therefore silently bypasses proxy plugin mode (fixed proxy + trusted CA, or
// fail-closed), creating an audit blind spot.
//
// A zero timeout means no client-level timeout (callers relying on context
// deadlines pass 0).
func NewHTTPClient(timeout time.Duration) *http.Client {
base := Shared()
return &http.Client{
Transport: NewHTTPPolicyRouter(base, base),
Transport: Shared(),
Timeout: timeout,
}
}
// NewExternalHTTPClient returns a client for user-provided, pre-signed, CDN,
// package-registry, and other non-platform URLs. It forces the external policy
// while preserving the shared proxy configuration and the historical behavior
// of unscoped transport providers. A zero timeout means no client-level timeout.
func NewExternalHTTPClient(timeout time.Duration) *http.Client {
return ClientForRequestClass(NewHTTPClient(timeout), exttransport.RequestClassExternal)
}
// noProxyTransport is a proxy-disabled clone of http.DefaultTransport, lazily
// built the first time LARK_CLI_NO_PROXY is observed set.
var noProxyTransport = sync.OnceValue(func() *http.Transport {

View File

@@ -88,24 +88,23 @@ func TestShared_NoProxyOverridesSystemProxy(t *testing.T) {
}
}
// TestHTTPClientConstructors verifies both the policy-routed client and its
// forced-external view retain explicit transports and configured timeouts.
func TestHTTPClientConstructors(t *testing.T) {
// TestNewHTTPClient verifies the factory wires the shared proxy-plugin-aware
// transport (instead of a bare client that bypasses proxy plugin mode).
func TestNewHTTPClient(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
unsetProxyPluginEnv(t)
resetProxyPluginState()
t.Setenv(EnvNoProxy, "")
for name, client := range map[string]*http.Client{
"routed": NewHTTPClient(7 * time.Second),
"external": NewExternalHTTPClient(7 * time.Second),
} {
if client.Transport == nil {
t.Fatalf("%s client transport is nil", name)
}
if client.Timeout != 7*time.Second {
t.Errorf("%s client timeout = %v, want 7s", name, client.Timeout)
}
c := NewHTTPClient(7 * time.Second)
if c.Transport == nil {
t.Fatal("NewHTTPClient transport is nil; want shared transport")
}
if c.Transport != Shared() {
t.Errorf("NewHTTPClient transport = %v, want Shared()", c.Transport)
}
if c.Timeout != 7*time.Second {
t.Errorf("NewHTTPClient timeout = %v, want 7s", c.Timeout)
}
}
@@ -154,32 +153,4 @@ func TestShared_MalformedConfigFailsClosedEvenWithNoProxy(t *testing.T) {
if err == nil {
t.Fatalf("RoundTrip() err = nil (resp=%v); malformed config must fail closed", resp)
}
for name, test := range map[string]struct {
client *http.Client
url string
}{
"platform": {
client: NewHTTPClient(time.Second),
url: "https://open.feishu.cn/open-apis/test",
},
"external": {
client: NewHTTPClient(time.Second),
url: "https://external.example/test",
},
"forced external": {
client: NewExternalHTTPClient(time.Second),
url: "https://external.example/test",
},
} {
t.Run(name, func(t *testing.T) {
resp, err := test.client.Get(test.url)
if err == nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
t.Fatalf("policy-routed client succeeded with malformed proxy config")
}
})
}
}

View File

@@ -62,7 +62,10 @@ func httpClient() *http.Client {
if DefaultClient != nil {
return DefaultClient
}
return transport.NewExternalHTTPClient(fetchTimeout)
return &http.Client{
Timeout: fetchTimeout,
Transport: transport.Shared(),
}
}
// updateState is persisted to disk for caching.

View File

@@ -4,7 +4,6 @@
package update
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -13,8 +12,6 @@ import (
"path/filepath"
"testing"
"time"
exttransport "github.com/larksuite/cli/extension/transport"
)
// roundTripFunc adapts a function to http.RoundTripper.
@@ -22,30 +19,6 @@ type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
type updateExternalProvider struct {
interceptor exttransport.Interceptor
}
func (p updateExternalProvider) Name() string { return "update-external-test" }
func (p updateExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return p.interceptor
}
func (updateExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassExternal
}
type updateExternalInterceptor struct {
calls int
}
func (i *updateExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-External-Route", "1")
return nil
}
// clearSkipEnv unsets all env vars that shouldSkip checks,
// preventing the host environment (e.g. CI=true) from polluting test results.
func clearSkipEnv(t *testing.T) {
@@ -269,46 +242,6 @@ func TestRefreshCache(t *testing.T) {
RefreshCache("1.0.0")
}
func TestHTTPClientUsesExternalRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARK_CLI_NO_PROXY", "")
previousClient := DefaultClient
DefaultClient = nil
t.Cleanup(func() { DefaultClient = previousClient })
previousProvider := exttransport.GetProvider()
interceptor := &updateExternalInterceptor{}
exttransport.Register(updateExternalProvider{interceptor: interceptor})
t.Cleanup(func() { exttransport.Register(previousProvider) })
previousTransport := http.DefaultTransport
var receivedHeader string
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
receivedHeader = req.Header.Get("X-External-Route")
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/npm/latest", nil)
if err != nil {
t.Fatal(err)
}
resp, err := httpClient().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if interceptor.calls != 1 || receivedHeader != "1" {
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
}
}
func TestPendingAtomicAccess(t *testing.T) {
// Initially nil
if got := GetPending(); got != nil {

View File

@@ -5,15 +5,11 @@ package validate
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"github.com/larksuite/cli/errs"
)
const (
@@ -38,9 +34,6 @@ func isRestrictedDownloadIP(ip net.IP) bool {
return true
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 0 { // RFC 1122 "this network"
return true
}
if v4[0] == 10 || v4[0] == 127 {
return true
}
@@ -59,9 +52,6 @@ func isRestrictedDownloadIP(ip net.IP) bool {
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) { // RFC2544 benchmarking
return true
}
if v4[0] >= 240 {
return true
}
return false
}
if ip.IsPrivate() {
@@ -86,42 +76,32 @@ func ValidateDownloadSourceURL(ctx context.Context, rawURL string) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("only http/https URLs are supported")
}
_, err = resolveDownloadHost(ctx, u.Hostname(), net.DefaultResolver.LookupIP)
return err
}
type downloadLookupIPFunc func(context.Context, string, string) ([]net.IP, error)
func resolveDownloadHost(ctx context.Context, rawHost string, lookupIP downloadLookupIPFunc) ([]net.IP, error) {
host := strings.TrimSpace(strings.ToLower(rawHost))
host := strings.TrimSpace(strings.ToLower(u.Hostname()))
if host == "" {
return nil, fmt.Errorf("URL host is required")
return fmt.Errorf("URL host is required")
}
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return nil, fmt.Errorf("local/internal host is not allowed")
return fmt.Errorf("local/internal host is not allowed")
}
if ip := net.ParseIP(host); ip != nil {
if isRestrictedDownloadIP(ip) {
return nil, fmt.Errorf("local/internal host is not allowed")
return fmt.Errorf("local/internal host is not allowed")
}
return []net.IP{ip}, nil
return nil
}
if lookupIP == nil {
lookupIP = net.DefaultResolver.LookupIP
}
ips, err := lookupIP(ctx, "ip", host)
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, fmt.Errorf("failed to resolve host")
return fmt.Errorf("failed to resolve host")
}
if len(ips) == 0 {
return nil, fmt.Errorf("failed to resolve host")
return fmt.Errorf("failed to resolve host")
}
for _, ip := range ips {
if isRestrictedDownloadIP(ip) {
return nil, fmt.Errorf("local/internal host is not allowed")
return fmt.Errorf("local/internal host is not allowed")
}
}
return ips, nil
return nil
}
// NewDownloadHTTPClient clones base client and enforces download-safe redirect
@@ -135,10 +115,7 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
}
cloned := *base
cloned.Transport = &downloadSchemeTransport{
base: cloneDownloadTransport(base.Transport),
allowHTTP: opts.AllowHTTP,
}
cloned.Transport = cloneDownloadTransport(base.Transport)
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= opts.MaxRedirects {
return fmt.Errorf("too many redirects")
@@ -161,310 +138,18 @@ func NewDownloadHTTPClient(base *http.Client, opts DownloadHTTPClientOptions) *h
return &cloned
}
type downloadSchemeTransport struct {
base http.RoundTripper
allowHTTP bool
}
func (t *downloadSchemeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, errs.NewInternalError(
errs.SubtypeUnknown,
"download transport received a nil request",
)
}
switch {
case strings.EqualFold(req.URL.Scheme, "https"):
case t.allowHTTP && strings.EqualFold(req.URL.Scheme, "http"):
default:
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"only https URLs are supported",
)
}
return t.base.RoundTrip(req)
}
type selectedDownloadProxyKey struct{}
type proxyAwareDownloadTransport struct {
selectProxy func(*http.Request) (*url.URL, error)
direct http.RoundTripper
proxied *http.Transport
lookupIP downloadLookupIPFunc
mu sync.Mutex
proxiedByTLSServer map[string]*http.Transport
}
func (t *proxyAwareDownloadTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req == nil || req.URL == nil {
return nil, fmt.Errorf("download transport received a nil request")
}
proxyURL, err := t.selectProxy(req)
if err != nil {
return nil, err
}
if proxyURL == nil {
return t.direct.RoundTrip(req)
}
targetIPs, err := resolveDownloadHost(req.Context(), req.URL.Hostname(), t.lookupIP)
if err != nil {
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"blocked download target: %v",
err,
).WithCause(err)
}
if strings.EqualFold(req.URL.Scheme, "http") && net.ParseIP(req.URL.Hostname()) == nil {
// HTTP proxies cannot pin the target IP separately from the Host header.
return nil, errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"plain HTTP hostname downloads through a proxy are not allowed",
).WithHint("use HTTPS or a literal public IP")
}
selected := *proxyURL
proxied := t.proxied
if strings.EqualFold(req.URL.Scheme, "https") {
proxied = t.proxiedTransportForTLSServer(req.URL.Hostname())
}
var lastErr error
for index, targetIP := range targetIPs {
proxiedReq, pinErr := pinDownloadRequestTargetToIP(req, targetIP)
if pinErr != nil {
return nil, pinErr
}
ctx := context.WithValue(proxiedReq.Context(), selectedDownloadProxyKey{}, &selected)
proxiedReq = proxiedReq.WithContext(ctx)
resp, roundTripErr := proxied.RoundTrip(proxiedReq)
if roundTripErr == nil {
if resp != nil {
// Hide the internal pinned URL from redirect handling.
resp.Request = req
}
return resp, nil
}
lastErr = roundTripErr
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if req.Context().Err() != nil {
break
}
if index+1 < len(targetIPs) && !canRetryDownloadTarget(req) {
break
func cloneDownloadTransport(base http.RoundTripper) *http.Transport {
var cloned *http.Transport
if src, ok := base.(*http.Transport); ok && src != nil {
cloned = src.Clone()
} else {
if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil {
cloned = def.Clone()
} else {
cloned = &http.Transport{}
}
}
return nil, lastErr
}
func (t *proxyAwareDownloadTransport) CloseIdleConnections() {
if closer, ok := t.direct.(interface{ CloseIdleConnections() }); ok {
closer.CloseIdleConnections()
}
t.proxied.CloseIdleConnections()
t.mu.Lock()
defer t.mu.Unlock()
for _, transport := range t.proxiedByTLSServer {
transport.CloseIdleConnections()
}
}
func (t *proxyAwareDownloadTransport) proxiedTransportForTLSServer(serverName string) *http.Transport {
if configured := t.proxied.TLSClientConfig; configured != nil && configured.ServerName != "" {
serverName = configured.ServerName
}
t.mu.Lock()
defer t.mu.Unlock()
if transport := t.proxiedByTLSServer[serverName]; transport != nil {
return transport
}
transport := t.proxied.Clone()
targetTLSConfig := cloneDownloadTLSConfig(transport.TLSClientConfig)
targetTLSConfig.ServerName = serverName
transport.TLSClientConfig = targetTLSConfig
configureHTTPSProxyTLSDialer(transport, t.proxied)
if t.proxiedByTLSServer == nil {
t.proxiedByTLSServer = make(map[string]*http.Transport)
}
t.proxiedByTLSServer[serverName] = transport
return transport
}
type blockedDownloadTransport struct {
err error
}
func (t *blockedDownloadTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, t.err
}
func cloneDownloadTransport(base http.RoundTripper) http.RoundTripper {
if base == nil {
base = http.DefaultTransport
}
if source, ok := base.(interface {
TransformHTTPTransport(func(*http.Transport) (http.RoundTripper, bool)) (http.RoundTripper, bool)
}); ok {
rebuilt, transformed := source.TransformHTTPTransport(newDownloadTransportLeaf)
if transformed && rebuilt != nil {
return rebuilt
}
}
if source, ok := base.(*http.Transport); ok && source != nil {
rebuilt, transformed := newDownloadTransportLeaf(source)
if transformed && rebuilt != nil {
return rebuilt
}
}
return &blockedDownloadTransport{err: errs.NewInternalError(
errs.SubtypeUnknown,
"cannot safely clone download transport %T",
base,
)}
}
func newDownloadTransportLeaf(source *http.Transport) (http.RoundTripper, bool) {
return newDownloadTransportLeafWithResolver(source, net.DefaultResolver.LookupIP)
}
func newDownloadTransportLeafWithResolver(source *http.Transport, lookupIP downloadLookupIPFunc) (http.RoundTripper, bool) {
if source == nil {
return nil, false
}
selectProxy := source.Proxy
direct := cloneDownloadHTTPTransport(source)
direct.Proxy = nil
configureDirectDownloadTransport(direct)
if selectProxy == nil {
return direct, true
}
// The proxied branch validates the requested URL before construction and
// on every redirect. Its TCP peer is the selected proxy, so applying the
// direct-origin IP guard there would incorrectly reject trusted loopback or
// private-network proxies. Freeze the selected proxy in request context so
// a stateful selector cannot switch the second lookup to direct egress.
proxied := cloneDownloadHTTPTransport(source)
proxied.Proxy = func(req *http.Request) (*url.URL, error) {
selected, ok := req.Context().Value(selectedDownloadProxyKey{}).(*url.URL)
if !ok || selected == nil {
return nil, fmt.Errorf("download proxy selection is missing")
}
cloned := *selected
return &cloned, nil
}
return &proxyAwareDownloadTransport{
selectProxy: selectProxy,
direct: direct,
proxied: proxied,
lookupIP: lookupIP,
proxiedByTLSServer: make(map[string]*http.Transport),
}, true
}
func cloneDownloadHTTPTransport(source *http.Transport) *http.Transport {
cloned := source.Clone()
if cloned.TLSNextProto == nil {
if _, ok := source.TLSNextProto["h2"]; ok {
cloned.ForceAttemptHTTP2 = true
}
}
return cloned
}
func pinDownloadRequestTargetToIP(req *http.Request, targetIP net.IP) (*http.Request, error) {
if req == nil || req.URL == nil {
return nil, fmt.Errorf("download request URL is missing")
}
if targetIP == nil || isRestrictedDownloadIP(targetIP) {
return nil, fmt.Errorf("blocked download target: local/internal host is not allowed")
}
originalHost := req.URL.Host
pinnedHost := targetIP.String()
if port := req.URL.Port(); port != "" {
pinnedHost = net.JoinHostPort(pinnedHost, port)
} else if strings.Contains(pinnedHost, ":") {
pinnedHost = "[" + pinnedHost + "]"
}
pinned := req.Clone(req.Context())
pinnedURL := *req.URL
pinnedURL.Host = pinnedHost
pinned.URL = &pinnedURL
pinned.Host = originalHost
return pinned, nil
}
func canRetryDownloadTarget(req *http.Request) bool {
if req == nil || req.Body != nil {
return false
}
return req.Method == http.MethodGet || req.Method == http.MethodHead
}
func cloneDownloadTLSConfig(config *tls.Config) *tls.Config {
if config == nil {
return &tls.Config{MinVersion: tls.VersionTLS12}
}
return config.Clone()
}
func configureHTTPSProxyTLSDialer(transport, source *http.Transport) {
if transport.DialTLSContext != nil || transport.DialTLS != nil {
return
}
proxyTLSConfig := cloneDownloadTLSConfig(source.TLSClientConfig)
transport.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
rawConn, err := dialDownloadProxy(ctx, source, network, addr)
if err != nil {
return nil, err
}
config := proxyTLSConfig.Clone()
serverName, _, splitErr := net.SplitHostPort(addr)
if splitErr != nil {
rawConn.Close()
return nil, fmt.Errorf("invalid HTTPS proxy address: %w", splitErr)
}
config.ServerName = serverName
tlsConn := tls.Client(rawConn, config)
handshakeCtx := ctx
cancel := func() {}
if source.TLSHandshakeTimeout > 0 {
handshakeCtx, cancel = context.WithTimeout(ctx, source.TLSHandshakeTimeout)
}
defer cancel()
if err := tlsConn.HandshakeContext(handshakeCtx); err != nil {
rawConn.Close()
return nil, err
}
return tlsConn, nil
}
}
func dialDownloadProxy(ctx context.Context, source *http.Transport, network, addr string) (net.Conn, error) {
if source.DialContext != nil {
return source.DialContext(ctx, network, addr)
}
if source.Dial != nil {
return source.Dial(network, addr)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
func configureDirectDownloadTransport(cloned *http.Transport) {
origDial := cloned.DialContext
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialConn(ctx, origDial, network, addr)
@@ -473,7 +158,7 @@ func configureDirectDownloadTransport(cloned *http.Transport) {
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, downloadTargetPolicyError(err)
return nil, err
}
return conn, nil
}
@@ -487,26 +172,13 @@ func configureDirectDownloadTransport(cloned *http.Transport) {
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, downloadTargetPolicyError(err)
}
return conn, nil
}
}
if cloned.DialTLS != nil {
origDialTLS := cloned.DialTLS
cloned.DialTLS = func(network, addr string) (net.Conn, error) {
conn, err := origDialTLS(network, addr)
if err != nil {
return nil, err
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, downloadTargetPolicyError(err)
}
return conn, nil
}
}
return cloned
}
// DialContextFunc is the signature for DialContext / DialTLSContext.
@@ -522,7 +194,7 @@ func WrapDialContextWithIPCheck(origDial DialContextFunc) DialContextFunc {
}
if err := validateConnRemoteIP(conn); err != nil {
conn.Close()
return nil, downloadTargetPolicyError(err)
return nil, err
}
return conn, nil
}
@@ -536,14 +208,6 @@ func dialConn(ctx context.Context, dialFn func(context.Context, string, string)
return d.DialContext(ctx, network, addr)
}
func downloadTargetPolicyError(err error) error {
return errs.NewSecurityPolicyError(
errs.SubtypeAccessDenied,
"blocked download target: %v",
err,
).WithCause(err)
}
func validateConnRemoteIP(conn net.Conn) error {
if conn == nil {
return fmt.Errorf("nil connection")

View File

@@ -1,529 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/larksuite/cli/errs"
)
func TestProxiedHTTPSDownloadPinsValidatedTargetIP(t *testing.T) {
const (
targetHost = "rebind.example"
targetIP = "203.0.113.10"
)
proxyCalled := make(chan struct{}, 1)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
proxyCalled <- struct{}{}
if req.Method != http.MethodConnect {
t.Errorf("proxy request method = %q, want CONNECT", req.Method)
}
if got := req.Host; got != targetIP+":443" {
t.Errorf("proxy CONNECT target = %q, want validated IP %q", got, targetIP+":443")
}
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{net.ParseIP(targetIP)}, nil
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
lookupIP,
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "https://"+targetHost+"/file", nil)
if err != nil {
t.Fatal(err)
}
pinned, err := pinDownloadRequestTargetToIP(req, net.ParseIP(targetIP))
if err != nil {
t.Fatal(err)
}
if pinned.Host != targetHost {
t.Fatalf("pinned request Host = %q, want %q", pinned.Host, targetHost)
}
if _, err := transport.RoundTrip(req); err == nil {
t.Fatal("RoundTrip() error = nil, want proxy rejection after CONNECT")
}
select {
case <-proxyCalled:
default:
t.Fatal("proxy was not called")
}
}
func TestRestrictedDownloadIPBlocksReservedIPv4(t *testing.T) {
for _, rawIP := range []string{"0.1.2.3", "240.0.0.1"} {
if !isRestrictedDownloadIP(net.ParseIP(rawIP)) {
t.Fatalf("%s was classified as safe", rawIP)
}
}
if isRestrictedDownloadIP(net.ParseIP("1.1.1.1")) {
t.Fatal("1.1.1.1 was classified as restricted")
}
}
func TestCloneDownloadTLSConfigSetsMinimumVersion(t *testing.T) {
if got := cloneDownloadTLSConfig(nil).MinVersion; got != tls.VersionTLS12 {
t.Fatalf("MinVersion = %d, want TLS 1.2", got)
}
configured := &tls.Config{MinVersion: tls.VersionTLS13}
if got := cloneDownloadTLSConfig(configured).MinVersion; got != tls.VersionTLS13 {
t.Fatalf("cloned MinVersion = %d, want TLS 1.3", got)
}
}
func TestCloneDownloadHTTPTransportPreservesHTTP2Policy(t *testing.T) {
tests := []struct {
name string
source *http.Transport
wantForce bool
wantH2Handler bool
wantProtocolMap bool
}{
{
name: "automatic",
source: &http.Transport{
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
},
wantForce: true,
},
{
name: "custom TLS without opt-in",
source: &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
},
},
{
name: "custom dial without opt-in",
source: &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("unused")
},
},
},
{
name: "explicit opt-in",
source: &http.Transport{ForceAttemptHTTP2: true},
wantForce: true,
},
{
name: "explicit h2 handler",
source: &http.Transport{
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{
"h2": func(string, *tls.Conn) http.RoundTripper { return nil },
},
},
wantH2Handler: true,
wantProtocolMap: true,
},
{
name: "explicit opt-out",
source: &http.Transport{
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
},
wantProtocolMap: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
cloned := cloneDownloadHTTPTransport(test.source)
if cloned.ForceAttemptHTTP2 != test.wantForce {
t.Fatalf("ForceAttemptHTTP2 = %v, want %v", cloned.ForceAttemptHTTP2, test.wantForce)
}
_, hasH2Handler := cloned.TLSNextProto["h2"]
if hasH2Handler != test.wantH2Handler {
t.Fatalf("h2 handler = %v, want %v", hasH2Handler, test.wantH2Handler)
}
if hasProtocolMap := cloned.TLSNextProto != nil; hasProtocolMap != test.wantProtocolMap {
t.Fatalf("TLSNextProto is non-nil = %v, want %v", hasProtocolMap, test.wantProtocolMap)
}
})
}
}
func TestCloneDownloadTransportPreservesAutomaticHTTP2(t *testing.T) {
source := &http.Transport{
Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"}),
}
rebuilt := cloneDownloadTransport(source)
proxyAware, ok := rebuilt.(*proxyAwareDownloadTransport)
if !ok {
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", rebuilt)
}
direct, ok := proxyAware.direct.(*http.Transport)
if !ok {
t.Fatalf("direct transport type = %T, want *http.Transport", proxyAware.direct)
}
for name, transport := range map[string]*http.Transport{
"direct": direct,
"proxied": proxyAware.proxied,
} {
if !transport.ForceAttemptHTTP2 {
t.Fatalf("%s ForceAttemptHTTP2 = false, want true", name)
}
}
}
func TestProxiedDownloadRejectsRestrictedResolvedTarget(t *testing.T) {
var proxyCalled atomic.Bool
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyCalled.Store(true)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
lookupIP := func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("127.0.0.1")}, nil
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
lookupIP,
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "http://rebind.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = transport.RoundTrip(req)
if err == nil {
t.Fatal("RoundTrip() error = nil, want restricted target rejection")
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if proxyCalled.Load() {
t.Fatal("proxy was called for a restricted resolved target")
}
}
func TestProxiedPlainHTTPHostnameRejectsLocalProxy(t *testing.T) {
var proxyCalled atomic.Bool
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
proxyCalled.Store(true)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
lookupIP := func(ctx context.Context, _, host string) ([]net.IP, error) {
if host == "public.example" {
return []net.IP{net.ParseIP("203.0.113.10")}, nil
}
return net.DefaultResolver.LookupIP(ctx, "ip", host)
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
lookupIP,
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "http://public.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = transport.RoundTrip(req)
if err == nil {
t.Fatal("RoundTrip() error = nil, want plain HTTP hostname rejection")
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
} else if problem.Hint != "use HTTPS or a literal public IP" {
t.Fatalf("RoundTrip() hint = %q, want recovery guidance", problem.Hint)
}
if proxyCalled.Load() {
t.Fatal("proxy was called for a plain HTTP hostname target")
}
}
func TestProxiedHTTPSDownloadTriesEveryValidatedTargetIP(t *testing.T) {
connectTargets := make(chan string, 2)
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
connectTargets <- req.Host
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(proxyURL)},
func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{
net.ParseIP("203.0.113.10"),
net.ParseIP("203.0.113.11"),
}, nil
},
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
req, err := http.NewRequest(http.MethodGet, "https://multi.example/file", nil)
if err != nil {
t.Fatal(err)
}
if _, err := transport.RoundTrip(req); err == nil {
t.Fatal("RoundTrip() error = nil, want proxy rejection")
}
for _, want := range []string{"203.0.113.10:443", "203.0.113.11:443"} {
select {
case got := <-connectTargets:
if got != want {
t.Fatalf("proxy CONNECT target = %q, want %q", got, want)
}
default:
t.Fatalf("proxy did not receive CONNECT target %q", want)
}
}
}
func TestCanRetryDownloadTargetOnlyAllowsBodylessReads(t *testing.T) {
for _, test := range []struct {
method string
body string
want bool
}{
{method: http.MethodGet, want: true},
{method: http.MethodHead, want: true},
{method: http.MethodPost},
{method: http.MethodGet, body: "body"},
} {
req, err := http.NewRequest(test.method, "https://download.example/file", strings.NewReader(test.body))
if err != nil {
t.Fatal(err)
}
if test.body == "" {
req.Body = nil
}
if got := canRetryDownloadTarget(req); got != test.want {
t.Fatalf("canRetryDownloadTarget(%s, body=%q) = %v, want %v", test.method, test.body, got, test.want)
}
}
}
func TestProxiedHTTPSTargetPreservesOriginalTLSServerName(t *testing.T) {
transport, ok := newDownloadTransportLeafWithResolver(
&http.Transport{Proxy: http.ProxyURL(&url.URL{Scheme: "http", Host: "proxy.example:8080"})},
func(context.Context, string, string) ([]net.IP, error) {
return []net.IP{net.ParseIP("203.0.113.10")}, nil
},
)
if !ok {
t.Fatal("newDownloadTransportLeafWithResolver() did not rebuild transport")
}
proxyAware, ok := transport.(*proxyAwareDownloadTransport)
if !ok {
t.Fatalf("transport type = %T, want *proxyAwareDownloadTransport", transport)
}
pinned := proxyAware.proxiedTransportForTLSServer("download.example")
if pinned.TLSClientConfig == nil {
t.Fatal("TLSClientConfig = nil")
}
if pinned.TLSClientConfig.ServerName != "download.example" {
t.Fatalf("TLS ServerName = %q, want download.example", pinned.TLSClientConfig.ServerName)
}
if proxyAware.proxied.TLSClientConfig != nil && proxyAware.proxied.TLSClientConfig.ServerName != "" {
t.Fatalf("base proxy TLS ServerName = %q, want unchanged", proxyAware.proxied.TLSClientConfig.ServerName)
}
}
func TestHTTPSProxyTLSDialerUsesLegacyDial(t *testing.T) {
wantErr := errors.New("legacy dial used")
source := &http.Transport{
Dial: func(string, string) (net.Conn, error) {
return nil, wantErr
},
}
target := source.Clone()
configureHTTPSProxyTLSDialer(target, source)
if target.DialTLSContext == nil {
t.Fatal("DialTLSContext = nil")
}
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); !errors.Is(err, wantErr) {
t.Fatalf("DialTLSContext() error = %v, want %v", err, wantErr)
}
}
func TestDirectDownloadLegacyDialTLSClosesRestrictedConnection(t *testing.T) {
clientConn, serverConn := net.Pipe()
t.Cleanup(func() { serverConn.Close() })
conn := &trackedDownloadConn{
Conn: clientConn,
remoteAddr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443},
}
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
DialTLS: func(string, string) (net.Conn, error) {
return conn, nil
},
})
if !ok {
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
}
transport, ok := rebuilt.(*http.Transport)
if !ok {
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
}
_, err := transport.DialTLS("tcp", "public.example:443")
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
t.Fatalf("DialTLS() error = %v, want restricted target rejection", err)
}
if problem, ok := errs.ProblemOf(err); !ok ||
problem.Category != errs.CategoryPolicy ||
problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("DialTLS() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if !conn.closed {
t.Fatal("restricted connection was not closed")
}
}
func TestDirectDownloadLegacyDialTLSPreservesDialError(t *testing.T) {
wantErr := errors.New("dial failed")
rebuilt, ok := newDownloadTransportLeaf(&http.Transport{
DialTLS: func(string, string) (net.Conn, error) {
return nil, wantErr
},
})
if !ok {
t.Fatal("newDownloadTransportLeaf() did not rebuild transport")
}
transport, ok := rebuilt.(*http.Transport)
if !ok {
t.Fatalf("rebuilt transport = %T, want *http.Transport", rebuilt)
}
if _, err := transport.DialTLS("tcp", "public.example:443"); !errors.Is(err, wantErr) {
t.Fatalf("DialTLS() error = %v, want %v", err, wantErr)
}
}
func TestHTTPSProxyTLSDialerRetainsHandshakeTimeout(t *testing.T) {
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
clientConn.Close()
serverConn.Close()
})
source := &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return clientConn, nil
},
TLSHandshakeTimeout: 50 * time.Millisecond,
}
target := source.Clone()
configureHTTPSProxyTLSDialer(target, source)
started := time.Now()
if _, err := target.DialTLSContext(context.Background(), "tcp", "proxy.example:443"); err == nil {
t.Fatal("DialTLSContext() error = nil, want TLS handshake timeout")
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("TLS handshake timeout took %s, want under 1s", elapsed)
}
}
func TestHTTPSProxyTLSDialerUsesProxyServerName(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
t.Cleanup(server.Close)
clientConn, serverConn := net.Pipe()
t.Cleanup(func() {
clientConn.Close()
serverConn.Close()
})
proxySNI := make(chan string, 1)
serverTLSConfig := server.TLS.Clone()
serverTLSConfig.GetConfigForClient = func(info *tls.ClientHelloInfo) (*tls.Config, error) {
proxySNI <- info.ServerName
return nil, nil
}
serverErr := make(chan error, 1)
go func() {
serverErr <- tls.Server(serverConn, serverTLSConfig).Handshake()
}()
roots := x509.NewCertPool()
roots.AddCert(server.Certificate())
source := &http.Transport{
DialContext: func(context.Context, string, string) (net.Conn, error) {
return clientConn, nil
},
TLSClientConfig: &tls.Config{
RootCAs: roots,
ServerName: "target.example.com",
},
}
target := source.Clone()
configureHTTPSProxyTLSDialer(target, source)
conn, err := target.DialTLSContext(context.Background(), "tcp", "example.com:443")
if err != nil {
t.Fatal(err)
}
conn.Close()
if err := <-serverErr; err != nil {
t.Fatal(err)
}
if got := <-proxySNI; got != "example.com" {
t.Fatalf("proxy TLS ServerName = %q, want example.com", got)
}
}
type trackedDownloadConn struct {
net.Conn
remoteAddr net.Addr
closed bool
}
func (c *trackedDownloadConn) RemoteAddr() net.Addr {
return c.remoteAddr
}
func (c *trackedDownloadConn) Close() error {
c.closed = true
return c.Conn.Close()
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package validate_test
import (
"context"
"crypto/tls"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/larksuite/cli/errs"
exttransport "github.com/larksuite/cli/extension/transport"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
)
type opaqueRoundTripper struct {
called bool
}
func (t *opaqueRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
t.called = true
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}
type downloadTestProvider struct {
interceptor exttransport.Interceptor
}
func (p downloadTestProvider) Name() string { return "download-test" }
func (p downloadTestProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return p.interceptor
}
type downloadHeaderInterceptor struct{}
func (downloadHeaderInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set("X-Use-Proxy", "1")
return nil
}
func TestNewDownloadHTTPClientPreservesPolicyRouterBaseTransport(t *testing.T) {
wantErr := errors.New("proxy policy blocked request")
base := &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) {
return nil, wantErr
},
}
router := internaltransport.NewHTTPPolicyRouter(base, base)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: router},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "https://external.example/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := download.Transport.RoundTrip(req)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if !errors.Is(err, wantErr) {
t.Fatalf("RoundTrip() error = %v, want preserved proxy error %v", err, wantErr)
}
}
func TestNewDownloadHTTPClientRejectsInitialHTTPBeforeTransport(t *testing.T) {
base := &opaqueRoundTripper{}
download := validate.NewDownloadHTTPClient(
&http.Client{Transport: base},
validate.DownloadHTTPClientOptions{},
)
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = download.Transport.RoundTrip(req)
if err == nil {
t.Fatal("RoundTrip() error = nil, want initial HTTP rejection")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
if base.called {
t.Fatal("base transport was called for a disallowed initial HTTP request")
}
}
func TestNewDownloadHTTPClientAllowsSelectedLoopbackProxy(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.URL.Host != "203.0.113.10" {
t.Errorf("proxy request target = %q, want 203.0.113.10", req.URL.Host)
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
base := &http.Transport{Proxy: http.ProxyURL(proxyURL)}
router := internaltransport.NewHTTPPolicyRouter(base, base)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: router},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := download.Do(req)
if err != nil {
t.Fatalf("download through selected loopback proxy: %v", err)
}
resp.Body.Close()
}
func TestNewDownloadHTTPClientSelectsProxyAfterOuterDecorators(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if got := req.Header.Get("X-Use-Proxy"); got != "1" {
t.Errorf("proxy received X-Use-Proxy = %q, want 1", got)
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(proxy.Close)
proxyURL, err := url.Parse(proxy.URL)
if err != nil {
t.Fatal(err)
}
wantErr := errors.New("proxy selector ran before decorators")
base := &http.Transport{Proxy: func(req *http.Request) (*url.URL, error) {
if req.Header.Get("X-Use-Proxy") != "1" {
return nil, wantErr
}
return proxyURL, nil
}}
previousProvider := exttransport.GetProvider()
exttransport.Register(downloadTestProvider{interceptor: downloadHeaderInterceptor{}})
t.Cleanup(func() { exttransport.Register(previousProvider) })
router := internaltransport.NewHTTPPolicyRouter(base, base)
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: router},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "http://203.0.113.10/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := download.Do(req)
if err != nil {
t.Fatalf("download through decorator-selected proxy: %v", err)
}
resp.Body.Close()
}
func TestNewDownloadHTTPClientGuardsLegacyDialTLS(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
}}
download := validate.NewDownloadHTTPClient(&http.Client{Transport: base}, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = download.Transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
var policyErr *errs.SecurityPolicyError
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
}
}
func TestNewDownloadHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
opaque := &opaqueRoundTripper{}
client := internaltransport.ClientForRequestClass(
&http.Client{Transport: opaque},
exttransport.RequestClassExternal,
)
download := validate.NewDownloadHTTPClient(client, validate.DownloadHTTPClientOptions{AllowHTTP: true})
req, err := http.NewRequest(http.MethodGet, "https://public.example/file", nil)
if err != nil {
t.Fatal(err)
}
_, err = download.Transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
if opaque.called {
t.Fatal("opaque transport was called after safe cloning failed")
}
}

View File

@@ -19,6 +19,9 @@ func SafeOutputPath(path string) (string, error) {
}
// SafeInputPath validates an upload/read source path for --file flags.
// Deliberately strict (relative-to-cwd only): several callers — drive sync,
// upload flags, the CI quality gates — treat "absolute paths rejected" as a
// load-bearing invariant. Out-of-tree content reaches flags via stdin ("-").
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}

View File

@@ -242,7 +242,7 @@ func TestSafeOutputPath_DeepNonExistentPathStaysInCWD(t *testing.T) {
}
}
func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
func TestSafeUploadPath_RejectsTempFileAbsolutePath(t *testing.T) {
// GIVEN: a real temp file (absolute path under os.TempDir())
f, err := os.CreateTemp("", "upload-test-*.bin")
if err != nil {
@@ -252,10 +252,11 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) {
f.Close()
t.Cleanup(func() { os.Remove(tmpPath) })
// WHEN: SafeUploadPath validates the absolute temp path
// WHEN: SafeInputPath validates the absolute temp path
_, err = SafeInputPath(tmpPath)
// THEN: absolute paths are rejected even in temp dir
// THEN: the strict validator rejects it — uploads / drive sync rely on
// relative-only; out-of-tree content reaches flags via stdin ("-")
if err == nil {
t.Fatal("expected error for absolute temp path, got nil")
}

View File

@@ -14,7 +14,6 @@ import (
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -75,9 +74,11 @@ func normalizeTimestamp(raw string) (string, error) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s)
}
//nolint:forbidigo // Presigned transfers use the external HTTP policy.
// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件
//
//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply.
func newFileTransferClient() *http.Client {
return transport.NewExternalHTTPClient(0)
return &http.Client{Transport: http.DefaultTransport}
}
// URL helpers for the file (storage) CLI commands.

View File

@@ -1,79 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"net/http"
"testing"
exttransport "github.com/larksuite/cli/extension/transport"
)
type appsExternalProvider struct {
interceptor exttransport.Interceptor
}
func (p appsExternalProvider) Name() string { return "apps-external-test" }
func (p appsExternalProvider) ResolveInterceptor(context.Context) exttransport.Interceptor {
return p.interceptor
}
func (appsExternalProvider) SupportsRequestClass(class exttransport.RequestClass) bool {
return class == exttransport.RequestClassExternal
}
type appsExternalInterceptor struct {
calls int
}
func (i *appsExternalInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
i.calls++
req.Header.Set("X-External-Route", "1")
return nil
}
type appsRoundTripFunc func(*http.Request) (*http.Response, error)
func (f appsRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFileTransferClientUsesExternalRequestClass(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARK_CLI_NO_PROXY", "")
previousProvider := exttransport.GetProvider()
interceptor := &appsExternalInterceptor{}
exttransport.Register(appsExternalProvider{interceptor: interceptor})
t.Cleanup(func() { exttransport.Register(previousProvider) })
previousTransport := http.DefaultTransport
var receivedHeader string
http.DefaultTransport = appsRoundTripFunc(func(req *http.Request) (*http.Response, error) {
receivedHeader = req.Header.Get("X-External-Route")
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
t.Cleanup(func() { http.DefaultTransport = previousTransport })
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/presigned/file", nil)
if err != nil {
t.Fatal(err)
}
resp, err := newFileTransferClient().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if interceptor.calls != 1 || receivedHeader != "1" {
t.Fatalf("external route = calls %d, header %q; want 1, %q", interceptor.calls, receivedHeader, "1")
}
}

View File

@@ -161,7 +161,7 @@ func TestShortcutsCatalog(t *testing.T) {
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", "+table-copy", "+table-copy-status",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",

View File

@@ -5,7 +5,6 @@ package base
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -387,10 +386,6 @@ func baseV3Path(parts ...string) string {
}
func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
return baseV3RawContext(runtime.Ctx(), runtime, method, path, params, data)
}
func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
for k, v := range params {
switch val := v.(type) {
@@ -414,7 +409,7 @@ func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, metho
}
h := make(http.Header)
h.Set("X-App-Id", runtime.Config.AppID)
resp, err := runtime.DoAPIWithContext(ctx, req, larkcore.WithHeaders(h))
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
if err != nil {
return nil, baseAPIBoundaryError(err, "API call failed")
}
@@ -509,11 +504,6 @@ func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[
return handleBaseAPIResult(result, err, "API call failed")
}
func baseV3CallContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
result, err := baseV3RawContext(ctx, runtime, method, path, params, data)
return handleBaseAPIResult(result, err, "API call failed")
}
func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResultAny(result, err, "API call failed")

View File

@@ -20,8 +20,6 @@ func Shortcuts() []common.Shortcut {
BaseTableCreate,
BaseTableUpdate,
BaseTableDelete,
BaseTableCopy,
BaseTableCopyStatus,
BaseFieldList,
BaseFieldGet,
BaseFieldCreate,

View File

@@ -1,119 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
const (
tableCopyRangeSchema = "schema"
tableCopyRangeAll = "all"
tableCopyScope = "base:table:create"
tableCopyTimeoutMax = 30 * time.Minute
tableCopyTaskIDMax = 1024
)
var BaseTableCopy = common.Shortcut{
Service: "base",
Command: "+table-copy",
Description: "Copy a table by ID or name; structure only by default",
Risk: "write",
Scopes: []string{tableCopyScope},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
{Name: "name", Desc: "target table name", Required: true},
{Name: "range", Default: tableCopyRangeSchema, Desc: "copy range; defaults to schema, use all only to include records", Enum: []string{tableCopyRangeSchema, tableCopyRangeAll}},
{Name: "wait", Type: "bool", Desc: "wait for an all-range copy task to finish"},
},
Tips: []string{
`Example: lark-cli base +table-copy --base-token <base_token> --table-id "Tasks" --name "Tasks copy"`,
"table-id accepts a table ID or name in the current Base.",
"The default copies schema only; use --range all only when records must also be copied.",
"Use --wait with --range all to wait locally; otherwise continue with the returned next_command.",
},
DryRun: dryRunTableCopy,
PostMount: func(cmd *cobra.Command) {
cmd.Flags().Duration("timeout", 5*time.Minute, "maximum time to wait for an asynchronous copy task (max 30m)")
},
Validate: validateTableCopy,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableCopy(ctx, runtime)
},
}
var BaseTableCopyStatus = common.Shortcut{
Service: "base",
Command: "+table-copy-status",
Description: "Get one table copy task status",
Risk: "read",
Scopes: []string{tableCopyScope},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "task-id", Desc: "opaque table copy task ID", Required: true},
},
Tips: []string{
"Use the opaque task_id returned by base +table-copy; this command queries status once.",
"If state is init or process, run the returned next_command later.",
},
DryRun: dryRunTableCopyStatus,
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
if strings.TrimSpace(taskID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id cannot be blank").WithParam("--task-id")
}
if len(taskID) > tableCopyTaskIDMax {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id must not exceed %d bytes", tableCopyTaskIDMax).WithParam("--task-id")
}
return nil
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableCopyStatus(ctx, runtime)
},
}
func validateTableCopy(_ context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("table-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table-id cannot be blank").WithParam("--table-id")
}
if strings.TrimSpace(runtime.Str("name")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name cannot be blank").WithParam("--name")
}
rangeValue := runtime.Str("range")
wait := runtime.Bool("wait")
timeoutChanged := runtime.Changed("timeout")
if rangeValue == tableCopyRangeSchema {
if wait {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wait requires --range all").WithParam("--wait")
}
if timeoutChanged {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --range all and --wait").WithParam("--timeout")
}
}
if timeoutChanged && !wait {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --wait").WithParam("--timeout")
}
timeout, err := tableCopyTimeout(runtime)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", err).WithParam("--timeout").WithCause(err)
}
if wait && (timeout <= 0 || timeout > tableCopyTimeoutMax) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout must be greater than 0 and at most 30m").WithParam("--timeout")
}
return nil
}
func tableCopyTimeout(runtime *common.RuntimeContext) (time.Duration, error) {
return runtime.Cmd.Flags().GetDuration("timeout")
}

View File

@@ -1,399 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
tableCopyStateInit = "init"
tableCopyStateProcess = "process"
tableCopyStateSuccess = "success"
)
type tableCopyTable struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
}
type tableCopySubmitResult struct {
Table tableCopyTable
TaskID string
State string
}
type tableCopyStatus struct {
TableID string
State string
}
type tableCopyOutput struct {
Table tableCopyTable `json:"table"`
Range string `json:"range,omitempty"`
State string `json:"state"`
Completed bool `json:"completed"`
TaskID string `json:"task_id,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
NextAction string `json:"next_action,omitempty"`
NextCommand string `json:"next_command,omitempty"`
}
func dryRunTableCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
baseToken := runtime.Str("base-token")
rangeValue := runtime.Str("range")
dry := common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/copy").
Desc("[1] Submit table copy").
Body(map[string]interface{}{
"name": runtime.Str("name"),
"range": rangeValue,
}).
Set("base_token", baseToken).
Set("table_id", runtime.Str("table-id"))
if runtime.Bool("wait") {
dry.POST("/open-apis/base/v3/bases/:base_token/copy_table_state").
Desc("[2] Poll with 3s exponential backoff, capped at 30s").
Body(map[string]interface{}{"task_id": "<task_id_from_step_1>"})
timeout, _ := tableCopyTimeout(runtime)
dry.Set("wait", true).Set("timeout", timeout.String())
} else {
dry.Set("wait", false)
}
return dry
}
func dryRunTableCopyStatus(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/copy_table_state").
Body(map[string]interface{}{"task_id": runtime.Str("task-id")}).
Set("base_token", runtime.Str("base-token"))
}
func executeTableCopy(ctx context.Context, runtime *common.RuntimeContext) error {
return executeTableCopyWithClock(ctx, runtime, realTableCopyClock{})
}
func executeTableCopyWithClock(ctx context.Context, runtime *common.RuntimeContext, clock tableCopyClock) error {
rangeValue := runtime.Str("range")
submit, err := submitTableCopy(runtime, rangeValue)
if err != nil {
return tableCopySubmissionError(err)
}
if rangeValue == tableCopyRangeSchema {
if submit.State != tableCopyStateSuccess {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "schema table copy returned non-success state %q", submit.State)
}
runtime.Out(tableCopyOutput{
Table: submit.Table,
Range: rangeValue,
State: submit.State,
Completed: true,
}, nil)
tableCopyProgressf(runtime, "Table copy completed: success")
return nil
}
if submit.State == tableCopyStateSuccess {
runtime.Out(tableCopyOutput{
Table: submit.Table,
Range: rangeValue,
State: submit.State,
Completed: true,
TaskID: submit.TaskID,
}, nil)
tableCopyProgressf(runtime, "Table copy completed: success")
return nil
}
if submit.TaskID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "all-range table copy response missing task_id")
}
if runtime.Bool("wait") {
tableCopyProgressf(runtime, "Table copy submitted: %s, task_id=%s", submit.State, submit.TaskID)
timeout, timeoutErr := tableCopyTimeout(runtime)
if timeoutErr != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", timeoutErr).WithParam("--timeout").WithCause(timeoutErr)
}
stopSpinner := runtime.StartSpinner("Waiting for table copy")
status, timedOut, pollErr := pollTableCopy(ctx, timeout, clock, func(ctx context.Context) (tableCopyStatus, error) {
status, err := queryTableCopyStatus(ctx, runtime, runtime.Str("base-token"), submit.TaskID)
if err != nil {
if problem, ok := errs.ProblemOf(err); ok {
tableCopyProgressf(runtime, "Table copy status query error: %s/%s", problem.Category, problem.Subtype)
} else {
tableCopyProgressf(runtime, "Table copy status query error")
}
return tableCopyStatus{}, err
}
tableCopyProgressf(runtime, "Table copy status: %s", status.State)
return status, nil
})
stopSpinner()
if pollErr != nil {
recoveryState := status.State
if recoveryState == "" {
recoveryState = submit.State
}
recovery := tableCopyOutput{
Table: submit.Table,
Range: rangeValue,
State: recoveryState,
Completed: false,
TaskID: submit.TaskID,
}
if tableCopyWaitCanContinue(pollErr) {
recovery.NextAction = "poll_status"
recovery.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
}
recoveryErr := runtime.OutPartialFailure(recovery, nil)
var partialFailure *output.PartialFailureError
if !errors.As(recoveryErr, &partialFailure) {
return recoveryErr
}
return tableCopyWaitError(pollErr)
}
if timedOut && status.State == "" {
// No status query completed before the deadline. The submit response
// is still the last known task state, so preserve it.
status.State = submit.State
}
out := tableCopyOutput{
Table: submit.Table,
Range: rangeValue,
State: status.State,
Completed: status.State == tableCopyStateSuccess,
TaskID: submit.TaskID,
TimedOut: timedOut,
}
if !out.Completed {
out.NextAction = "poll_status"
out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
tableCopyProgressf(runtime, "Table copy is not complete; use next_command from stdout to continue")
}
runtime.Out(out, nil)
return nil
}
out := tableCopyOutput{
Table: submit.Table,
Range: rangeValue,
State: submit.State,
Completed: submit.State == tableCopyStateSuccess,
TaskID: submit.TaskID,
}
if !out.Completed {
out.NextAction = "poll_status"
out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID)
tableCopyProgressf(runtime, "Table copy is running asynchronously; use next_command from stdout to continue")
}
runtime.Out(out, nil)
return nil
}
func tableCopySubmissionError(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork {
return err
}
if problem.Subtype != errs.SubtypeNetworkTimeout && problem.Subtype != errs.SubtypeNetworkTransport {
return err
}
problem.Message = "table copy submission outcome is unknown because the response was not received"
problem.Hint = "Do not retry the copy automatically. Manually confirm whether the target table was created before deciding the next action."
problem.Retryable = false
return err
}
func tableCopyWaitCanContinue(err error) bool {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return true
}
if problem, ok := errs.ProblemOf(err); ok {
switch problem.Category {
case errs.CategoryAuthentication, errs.CategoryAuthorization:
return true
}
}
return tableCopyPollErrorRetryable(err)
}
func tableCopyWaitError(err error) error {
if !tableCopyWaitCanContinue(err) {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithCause(err)
}
hint := "The copy task was already submitted; do not submit it again. Read task_id from the submit output and continue with lark-cli base +table-copy-status using the same identity."
if errors.Is(err, context.Canceled) {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "table copy status polling was canceled").WithHint("%s", hint).WithCause(err)
}
if errors.Is(err, context.DeadlineExceeded) {
return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "table copy status polling timed out").WithHint("%s", hint).WithCause(err)
}
if problem, ok := errs.ProblemOf(err); ok {
if problem.Hint == "" {
problem.Hint = hint
} else {
problem.Hint += " " + hint
}
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithHint("%s", hint).WithCause(err)
}
func executeTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
taskID := runtime.Str("task-id")
status, err := queryTableCopyStatus(ctx, runtime, baseToken, taskID)
if err != nil {
return err
}
out := tableCopyOutput{
Table: tableCopyTable{ID: status.TableID},
State: status.State,
Completed: status.State == tableCopyStateSuccess,
TaskID: taskID,
}
if !out.Completed {
out.NextAction = "poll_status"
out.NextCommand = tableCopyNextCommand(runtime, baseToken, taskID)
}
runtime.Out(out, nil)
tableCopyProgressf(runtime, "Table copy status: %s", status.State)
return nil
}
func submitTableCopy(runtime *common.RuntimeContext, rangeValue string) (tableCopySubmitResult, error) {
baseToken := runtime.Str("base-token")
tableRef := runtime.Str("table-id")
body := map[string]interface{}{
"name": runtime.Str("name"),
"range": rangeValue,
}
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableRef, "copy"), nil, body)
if err != nil {
return tableCopySubmitResult{}, err
}
return projectTableCopySubmit(data)
}
func projectTableCopySubmit(data map[string]interface{}) (tableCopySubmitResult, error) {
tableData := common.GetMap(data, "table")
result := tableCopySubmitResult{
Table: tableCopyTable{
ID: strings.TrimSpace(common.GetString(tableData, "id")),
Name: common.GetString(tableData, "name"),
},
TaskID: strings.TrimSpace(common.GetString(data, "task_id")),
State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))),
}
if result.Table.ID == "" {
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response missing table.id")
}
if len(result.TaskID) > tableCopyTaskIDMax {
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response task_id exceeds %d bytes", tableCopyTaskIDMax)
}
switch result.State {
case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess:
return result, nil
default:
return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response has invalid state %q", result.State)
}
}
func queryTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext, baseToken, taskID string) (tableCopyStatus, error) {
data, err := baseV3CallContext(
ctx,
runtime,
"POST",
baseV3Path("bases", baseToken, "copy_table_state"),
nil,
map[string]interface{}{"task_id": taskID},
)
if err != nil {
return tableCopyStatus{}, tableCopyStatusError(err)
}
return projectTableCopyStatus(data)
}
func tableCopyStatusError(err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem.Code != 800010109 {
return err
}
var validationErr *errs.ValidationError
if errors.As(err, &validationErr) {
validationErr.WithParam("--task-id")
return err
}
classified := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", problem.Message).
WithParam("--task-id").
WithCode(problem.Code).
WithCause(err)
if problem.Hint != "" {
classified.WithHint("%s", problem.Hint)
}
if problem.LogID != "" {
classified.WithLogID(problem.LogID)
}
return classified
}
func projectTableCopyStatus(data map[string]interface{}) (tableCopyStatus, error) {
status := tableCopyStatus{
TableID: strings.TrimSpace(common.GetString(data, "table_id")),
State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))),
}
if status.TableID == "" {
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response missing table_id")
}
switch status.State {
case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess:
return status, nil
case "failed":
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status returned state=failed in a success envelope; the API must return task failures through the top-level error protocol")
default:
return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response has invalid state %q", status.State)
}
}
func tableCopyNextCommand(runtime *common.RuntimeContext, baseToken, taskID string) string {
parts := []string{"lark-cli"}
if runtime.Cmd.Flags().Lookup("profile") != nil && runtime.Changed("profile") {
profile, _ := runtime.Cmd.Flags().GetString("profile")
if strings.TrimSpace(profile) != "" {
parts = append(parts, "--profile", tableCopyShellArg(profile))
}
}
parts = append(parts,
"base", "+table-copy-status",
"--base-token", tableCopyShellArg(baseToken),
"--task-id", tableCopyShellArg(taskID),
"--as", string(runtime.As()),
)
return strings.Join(parts, " ")
}
func tableCopyShellArg(value string) string {
if value != "" && strings.IndexFunc(value, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && !strings.ContainsRune("._~-", r)
}) == -1 {
return value
}
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func tableCopyProgressf(runtime *common.RuntimeContext, format string, args ...interface{}) {
if runtime == nil || runtime.IO() == nil || runtime.IO().ErrOut == nil {
return
}
fmt.Fprintf(runtime.IO().ErrOut, format+"\n", args...)
}

View File

@@ -1,139 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"time"
"github.com/larksuite/cli/errs"
)
const (
tableCopyPollInitial = 3 * time.Second
tableCopyPollMax = 30 * time.Second
)
type tableCopyTimer interface {
C() <-chan time.Time
Stop() bool
}
type tableCopyClock interface {
Now() time.Time
NewTimer(time.Duration) tableCopyTimer
}
type tableCopyStatusFetcher func(context.Context) (tableCopyStatus, error)
type realTableCopyClock struct{}
func (realTableCopyClock) Now() time.Time { return time.Now() }
func (realTableCopyClock) NewTimer(duration time.Duration) tableCopyTimer {
return realTableCopyTimer{Timer: time.NewTimer(duration)}
}
type realTableCopyTimer struct {
*time.Timer
}
func (t realTableCopyTimer) C() <-chan time.Time { return t.Timer.C }
func pollTableCopy(
ctx context.Context,
timeout time.Duration,
clock tableCopyClock,
fetch tableCopyStatusFetcher,
) (tableCopyStatus, bool, error) {
deadline := clock.Now().Add(timeout)
delay := tableCopyPollInitial
var lastStatus tableCopyStatus
var lastErr error
hasStatus := false
for {
remaining := deadline.Sub(clock.Now())
if remaining <= 0 {
if !hasStatus && lastErr != nil {
return tableCopyStatus{}, false, lastErr
}
return lastStatus, true, nil
}
if delay > remaining {
delay = remaining
}
timer := clock.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return lastStatus, false, ctx.Err()
case <-timer.C():
}
if !clock.Now().Before(deadline) {
if !hasStatus && lastErr != nil {
return tableCopyStatus{}, false, lastErr
}
return lastStatus, true, nil
}
requestBudget := deadline.Sub(clock.Now())
if requestBudget <= 0 {
if !hasStatus && lastErr != nil {
return tableCopyStatus{}, false, lastErr
}
return lastStatus, true, nil
}
fetchCtx, cancelFetch := context.WithTimeout(ctx, requestBudget)
status, err := fetch(fetchCtx)
cancelFetch()
if ctx.Err() != nil {
return lastStatus, false, ctx.Err()
}
if !clock.Now().Before(deadline) {
if !hasStatus && err != nil {
return tableCopyStatus{}, false, err
}
return lastStatus, true, nil
}
if err != nil {
if !tableCopyPollErrorRetryable(err) {
return lastStatus, false, err
}
lastErr = err
} else {
lastStatus = status
hasStatus = true
switch status.State {
case tableCopyStateSuccess:
return status, false, nil
case tableCopyStateInit, tableCopyStateProcess:
default:
return lastStatus, false, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status has invalid state %q", status.State)
}
}
delay *= 2
if delay > tableCopyPollMax {
delay = tableCopyPollMax
}
}
}
func tableCopyPollErrorRetryable(err error) bool {
problem, ok := errs.ProblemOf(err)
if !ok {
return false
}
if problem.Category == errs.CategoryNetwork {
switch problem.Subtype {
case errs.SubtypeNetworkTimeout, errs.SubtypeNetworkTransport, errs.SubtypeNetworkServer:
return true
default:
return false
}
}
return problem.Category == errs.CategoryAPI && problem.Retryable
}

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,7 @@ type RuntimeContext struct {
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
inputResolved map[string]bool // flags whose value was replaced by @file / stdin content in resolveInputFlags; see InputResolvedFromSource
}
// ── Identity ──
@@ -450,12 +451,6 @@ func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interfa
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
// the identity → token logic across the generic and shortcut API paths.
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
}
// DoAPIWithContext executes a raw Lark SDK request using callCtx for request
// cancellation and deadlines while preserving the shortcut's resolved identity.
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
@@ -463,7 +458,7 @@ func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkco
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
opts = append(opts, optFn)
}
return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...)
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),
@@ -1022,6 +1017,25 @@ func stripUTF8BOM(s string) string {
return strings.TrimPrefix(s, "\uFEFF")
}
// InputResolvedFromSource reports whether the named flag's value was loaded
// from an external source (@file or stdin `-`) by resolveInputFlags, as
// opposed to typed inline on the command line. Domain guards that apply
// shape heuristics to inline values ("this looks like a file path — did you
// forget the @?") must skip resolved values: their content was already read
// from the right place and may legitimately look like anything, including a
// path. Without this bit such a guard re-rejects correct @file / stdin
// invocations, because by the time Validate runs both arrive as plain text.
func (ctx *RuntimeContext) InputResolvedFromSource(name string) bool {
return ctx.inputResolved[name]
}
func (ctx *RuntimeContext) markInputResolved(name string) {
if ctx.inputResolved == nil {
ctx.inputResolved = map[string]bool{}
}
ctx.inputResolved[name] = true
}
// 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 {
@@ -1061,6 +1075,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
// cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
rctx.markInputResolved(fl.Name)
continue
}
@@ -1097,6 +1112,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
rctx.markInputResolved(fl.Name)
continue
}
}

View File

@@ -43,6 +43,9 @@ func TestResolveInputFlags_DirectValue(t *testing.T) {
if got := rctx.Str("markdown"); got != "hello world" {
t.Errorf("expected %q, got %q", "hello world", got)
}
if rctx.InputResolvedFromSource("markdown") {
t.Error("inline value must not be marked as resolved from a source")
}
}
func TestResolveInputFlags_Stdin(t *testing.T) {
@@ -55,6 +58,9 @@ func TestResolveInputFlags_Stdin(t *testing.T) {
if got := rctx.Str("markdown"); got != "content from stdin" {
t.Errorf("expected %q, got %q", "content from stdin", got)
}
if !rctx.InputResolvedFromSource("markdown") {
t.Error("stdin value should be marked as resolved from a source")
}
}
func TestResolveInputFlags_File(t *testing.T) {
@@ -75,6 +81,27 @@ func TestResolveInputFlags_File(t *testing.T) {
if got := rctx.Str("markdown"); got != content {
t.Errorf("expected %q, got %q", content, got)
}
if !rctx.InputResolvedFromSource("markdown") {
t.Error("@file value should be marked as resolved from a source")
}
}
// TestResolveInputFlags_EscapedAtStaysInline pins that the @@ escape is
// inline content (a literal leading @), not an external source — heuristic
// guards keyed on InputResolvedFromSource must still see it.
func TestResolveInputFlags_EscapedAtStaysInline(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"markdown": "@@handle"}, "")
flags := []Flag{{Name: "markdown", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("markdown"); got != "@handle" {
t.Errorf("expected %q, got %q", "@handle", got)
}
if rctx.InputResolvedFromSource("markdown") {
t.Error("escaped @@ value must not be marked as resolved from a source")
}
}
func TestResolveInputFlags_EmptyFile(t *testing.T) {

View File

@@ -39,6 +39,13 @@ func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, i
return rctx
}
// TestMarkInputResolved marks a flag as resolved from @file / stdin, so
// domain tests can exercise guards that branch on InputResolvedFromSource
// without wiring the full resolveInputFlags path.
func TestMarkInputResolved(rctx *RuntimeContext, name string) {
rctx.markInputResolved(name)
}
// TestNewRuntimeContextForAPI creates a RuntimeContext ready for HTTP tests:
// sets Cmd, Config, Factory, context, and the requested identity so callers
// can invoke DoAPI / CallAPI directly without wiring through a cobra parent

View File

@@ -40,6 +40,12 @@ func (c docCoverHTTPStatusCause) Error() string {
return http.StatusText(int(c))
}
type docCoverURLGuardError string
func (e docCoverURLGuardError) Error() string {
return string(e)
}
var docCoverAllowedContentTypes = map[string]string{
"image/gif": ".gif",
"image/jpeg": ".jpg",
@@ -536,7 +542,7 @@ func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, ra
return nil, "", err
}
baseClient, err := runtime.Factory.ExternalHTTPClient()
baseClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
}
@@ -667,9 +673,6 @@ func isUnsafeDocCoverIP(ip net.IP) bool {
return true
}
if v4 := ip.To4(); v4 != nil {
if v4[0] == 0 {
return true
}
if v4[0] == 10 || v4[0] == 127 {
return true
}
@@ -698,15 +701,13 @@ func isUnsafeDocCoverIP(ip net.IP) bool {
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
if base == nil {
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.ExternalHTTPClient.
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
}
cloned := *base
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
}
cloned.Transport = validate.NewDownloadHTTPClient(base, validate.DownloadHTTPClientOptions{ //nolint:forbidigo // guarded external download
MaxRedirects: 3,
}).Transport
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
if len(via) >= 3 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
@@ -722,3 +723,73 @@ func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo
}
return &cloned
}
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
var cloned *http.Transport
if src, ok := base.(*http.Transport); ok && src != nil {
cloned = src.Clone()
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
cloned = def.Clone()
} else {
cloned = &http.Transport{}
}
cloned.Proxy = nil
origDial := cloned.DialContext
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
if err != nil {
return nil, err
}
if err := validateDocCoverConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
if cloned.DialTLSContext != nil {
origDialTLS := cloned.DialTLSContext
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
if err != nil {
return nil, err
}
if err := validateDocCoverConnRemoteIP(conn); err != nil {
conn.Close()
return nil, err
}
return conn, nil
}
}
return cloned
}
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
if dialFn != nil {
return dialFn(ctx, network, addr)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
func validateDocCoverConnRemoteIP(conn net.Conn) error {
if conn == nil {
return docCoverURLGuardError("nil connection")
}
addr := conn.RemoteAddr()
if addr == nil {
return docCoverURLGuardError("missing remote address")
}
host, _, err := net.SplitHostPort(addr.String())
if err != nil {
host = addr.String()
}
ip := net.ParseIP(strings.Trim(host, "[]"))
if ip == nil {
return docCoverURLGuardError("invalid remote IP")
}
if isUnsafeDocCoverIP(ip) {
return docCoverURLGuardError("local/internal host is not allowed")
}
return nil
}

View File

@@ -386,7 +386,6 @@ func TestValidateDocCoverURLHost(t *testing.T) {
func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
for _, rawIP := range []string{
"0.1.2.3",
"10.0.0.1",
"127.0.0.1",
"169.254.1.1",
@@ -407,26 +406,17 @@ func TestDocCoverIPSafetyBlocksSpecialRanges(t *testing.T) {
}
}
func TestDocCoverHTTPClientPreservesProxyPolicy(t *testing.T) {
proxyErr := errors.New("proxy selected")
directErr := errors.New("direct dialed")
baseTransport := &http.Transport{
Proxy: func(*http.Request) (*url.URL, error) {
return nil, proxyErr
},
DialContext: func(context.Context, string, string) (net.Conn, error) {
return nil, directErr
},
}
func TestDocCoverHTTPClientDoesNotUseProxy(t *testing.T) {
baseTransport := &http.Transport{Proxy: http.ProxyFromEnvironment}
baseClient := &http.Client{Transport: baseTransport}
client := newDocCoverHTTPClient(baseClient)
req, err := http.NewRequest(http.MethodGet, "https://203.0.113.10/cover.png", nil)
if err != nil {
t.Fatal(err)
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client transport = %T, want *http.Transport", client.Transport)
}
if _, err := client.Transport.RoundTrip(req); !errors.Is(err, proxyErr) {
t.Fatalf("RoundTrip() error = %v, want proxy policy error %v", err, proxyErr)
if transport.Proxy != nil {
t.Fatal("cover URL downloader must not inherit proxy settings")
}
if baseTransport.Proxy == nil {
t.Fatal("base transport proxy was mutated")
@@ -456,6 +446,21 @@ func TestDocCoverHTTPClientRedirectValidation(t *testing.T) {
}
}
func TestDocCoverConnRemoteIPValidation(t *testing.T) {
if err := validateDocCoverConnRemoteIP(nil); err == nil {
t.Fatal("expected nil connection error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{}); err == nil {
t.Fatal("expected missing remote address error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: testAddr("not-ip")}); err == nil {
t.Fatal("expected invalid remote IP error")
}
if err := validateDocCoverConnRemoteIP(docCoverRemoteAddrConn{addr: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 443}}); err == nil {
t.Fatal("expected local remote IP error")
}
}
func TestDocCoverURLFileName(t *testing.T) {
cases := []struct {
raw string
@@ -657,6 +662,16 @@ func (c docCoverRemoteAddrConn) RemoteAddr() net.Addr {
return c.addr
}
type testAddr string
func (a testAddr) Network() string {
return "test"
}
func (a testAddr) String() string {
return string(a)
}
type repeatByteReader byte
func (r repeatByteReader) Read(p []byte) (int, error) {
@@ -695,61 +710,3 @@ func decodeDocResourceOutput(t *testing.T, stdout *bytes.Buffer) docResourceOutp
}
return out
}
type opaqueDocCoverTransport struct {
called bool
}
func (t *opaqueDocCoverTransport) RoundTrip(req *http.Request) (*http.Response, error) {
t.called = true
return &http.Response{StatusCode: http.StatusNoContent, Body: http.NoBody, Request: req}, nil
}
func TestNewDocCoverHTTPClientFailsClosedForOpaqueTransport(t *testing.T) {
opaque := &opaqueDocCoverTransport{}
client := newDocCoverHTTPClient(&http.Client{Transport: opaque})
req, err := http.NewRequest(http.MethodGet, "https://public.example/cover.png", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "cannot safely clone download transport") {
t.Fatalf("RoundTrip() error = %v, want fail-closed clone error", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("RoundTrip() problem = %#v, %v; want internal/unknown", problem, ok)
}
if opaque.called {
t.Fatal("opaque transport was called after safe cloning failed")
}
}
func TestNewDocCoverHTTPClientGuardsLegacyDialTLS(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)
base := &http.Transport{DialTLS: func(_, _ string) (net.Conn, error) {
return tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true}) //nolint:gosec // local TLS server verifies the connection guard.
}}
client := newDocCoverHTTPClient(&http.Client{Transport: base})
req, err := http.NewRequest(http.MethodGet, "https://public.example/cover.png", nil)
if err != nil {
t.Fatal(err)
}
_, err = client.Transport.RoundTrip(req)
if err == nil || !strings.Contains(err.Error(), "local/internal host is not allowed") {
t.Fatalf("RoundTrip() error = %v, want legacy DialTLS IP guard", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryPolicy || problem.Subtype != errs.SubtypeAccessDenied {
t.Fatalf("RoundTrip() problem = %#v, %v; want policy/access_denied", problem, ok)
}
var policyErr *errs.SecurityPolicyError
if !errors.As(err, &policyErr) || policyErr.Cause == nil {
t.Fatalf("RoundTrip() error = %T, want policy error with cause", err)
}
}

View File

@@ -199,7 +199,7 @@ func startURLDownload(ctx context.Context, runtime *common.RuntimeContext, rawUR
WithCause(err)
}
httpClient, err := runtime.Factory.ExternalHTTPClient()
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
}

View File

@@ -28,7 +28,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -44,25 +43,6 @@ func (f shortcutRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, err
return f(req)
}
type shortcutPolicyDecorator struct {
base http.RoundTripper
fn shortcutRoundTripFunc
}
func (t *shortcutPolicyDecorator) BaseRoundTripper() http.RoundTripper {
return t.base
}
func (t *shortcutPolicyDecorator) WithBaseRoundTripper(base http.RoundTripper) http.RoundTripper {
cloned := *t
cloned.base = base
return &cloned
}
func (t *shortcutPolicyDecorator) RoundTrip(req *http.Request) (*http.Response, error) {
return t.fn(req)
}
func shortcutJSONResponse(status int, body interface{}) *http.Response {
b, _ := json.Marshal(body)
return &http.Response{
@@ -921,50 +901,6 @@ func TestStartURLDownloadBlockedURLCarriesParam(t *testing.T) {
}
}
func TestStartURLDownloadUsesExternalRequestClass(t *testing.T) {
platform := &shortcutPolicyDecorator{
base: http.DefaultTransport,
fn: func(req *http.Request) (*http.Response, error) {
return shortcutRawResponse(http.StatusBadGateway, nil, nil), nil
},
}
external := &shortcutPolicyDecorator{
base: http.DefaultTransport,
fn: func(req *http.Request) (*http.Response, error) {
resp := shortcutRawResponse(http.StatusOK, []byte("image"), nil)
resp.Request = req
return resp, nil
},
}
runtime := &common.RuntimeContext{
Factory: &cmdutil.Factory{
HttpClient: func() (*http.Client, error) {
return &http.Client{
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
}, nil
},
},
}
resp, _, err := startURLDownload(
context.Background(),
runtime,
"https://open.feishu.cn/presigned/image.png",
"--image",
)
if err != nil {
t.Fatalf("startURLDownload() error = %v, want external route", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if got := string(body); got != "image" {
t.Fatalf("download body = %q, want external payload", got)
}
}
// TestResolveLocalMediaImage verifies that resolveLocalMedia can upload an image
// via uploadImageToIM without double path validation.
func TestResolveLocalMediaImage(t *testing.T) {

View File

@@ -1790,7 +1790,7 @@ func downloadAttachmentContent(runtime *common.RuntimeContext, downloadURL strin
return nil, mailInvalidResponseError("attachment download URL has no host")
}
httpClient, err := runtime.Factory.ExternalHTTPClient()
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeSDKError, "failed to get HTTP client: %v", err).WithCause(err)
}

View File

@@ -9,7 +9,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
@@ -21,7 +20,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -398,36 +396,6 @@ func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) {
}
}
func TestDownloadAttachmentContentUsesExternalRequestClass(t *testing.T) {
platform := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
external := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("attachment data")),
Request: req,
}, nil
})
rt := newDownloadRuntime(t, &http.Client{
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
})
data, err := downloadAttachmentContent(rt, "https://open.feishu.cn/presigned/file")
if err != nil {
t.Fatalf("downloadAttachmentContent() error = %v, want external route", err)
}
if got := string(data); got != "attachment data" {
t.Fatalf("downloadAttachmentContent() = %q, want external payload", got)
}
}
// ---------------------------------------------------------------------------
// newOutputRuntime — helper for tests that call runtime.Out / runtime.IO()
// ---------------------------------------------------------------------------

View File

@@ -228,7 +228,7 @@ func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filenam
return nil, "", mailInvalidResponseError("signature image download: URL has no host")
}
httpClient, err := runtime.Factory.ExternalHTTPClient()
httpClient, err := runtime.Factory.HttpClient()
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
}

View File

@@ -6,19 +6,15 @@ package mail
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/shortcuts/common"
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
@@ -119,47 +115,6 @@ func TestContentTypeFromFilename(t *testing.T) {
}
}
func TestDownloadSignatureImageUsesExternalRequestClass(t *testing.T) {
const payload = `{"code":21000,"msg":"application-defined response","data":{"cli_hint":"external"}}`
platform := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
external := signatureRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(payload)),
Request: req,
}, nil
})
client := &http.Client{Transport: internaltransport.NewHTTPPolicyRouter(
&auth.SecurityPolicyTransport{Base: platform},
external,
)}
factory := &cmdutil.Factory{
HttpClient: func() (*http.Client, error) { return client, nil },
}
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{}, nil)
runtime.Factory = factory
data, contentType, err := downloadSignatureImage(
runtime,
"https://open.feishu.cn/signature.png",
"signature.png",
)
if err != nil {
t.Fatalf("downloadSignatureImage() error = %v, want external response passthrough", err)
}
if string(data) != payload || contentType != "application/json" {
t.Fatalf("download = %q (%s), want external response", data, contentType)
}
}
func TestSignatureCIDsNilSig(t *testing.T) {
if cids := signatureCIDs(nil); cids != nil {
t.Fatalf("expected nil slice for nil sig, got %v", cids)
@@ -251,12 +206,6 @@ func stubSigListResponse(reg *httpmock.Registry, mailboxID string, sigs []map[st
})
}
type signatureRoundTripFunc func(*http.Request) (*http.Response, error)
func (f signatureRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestAutoResolveSignatureID_APIFailureReturnsEmpty(t *testing.T) {
rt, reg := newSigTestRuntime(t)
reg.Register(&httpmock.Stub{

View File

@@ -145,8 +145,13 @@ var MinutesDownload = common.Shortcut{
seen := make(map[string]int)
usedNames := make(map[string]bool)
// Clone the external client so timeout changes stay local.
baseClient, err := runtime.Factory.ExternalHTTPClient()
// Clone the factory client for download use. We clone the struct (not the
// pointer) to avoid mutating the shared singleton's Timeout. The original
// transport chain is preserved so security headers and test mocks still work.
// SSRF protection: ValidateDownloadSourceURL (URL-level) + CheckRedirect
// (redirect-level). Transport-level IP check is intentionally omitted because
// download URLs originate from the trusted Lark API, not user input.
baseClient, err := runtime.Factory.HttpClient()
if err != nil {
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
}

View File

@@ -8,7 +8,6 @@ import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strings"
@@ -22,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
internaltransport "github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -32,12 +30,6 @@ import (
var warmOnce sync.Once
type minutesRoundTripFunc func(*http.Request) (*http.Response, error)
func (f minutesRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func warmTokenCache(t *testing.T) {
t.Helper()
warmOnce.Do(func() {
@@ -224,52 +216,6 @@ func TestDownload_ServerFilenameTraversalStaysInOutputDir(t *testing.T) {
}
}
func TestDownloadUsesExternalRequestClass(t *testing.T) {
chdir(t, t.TempDir())
const downloadURL = "https://open.feishu.cn/presigned/download"
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(mediaStub("tok001", downloadURL))
platform := minutesRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Header: make(http.Header),
Body: http.NoBody,
Request: req,
}, nil
})
external := minutesRoundTripFunc(func(req *http.Request) (*http.Response, error) {
payload := []byte("media")
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"video/mp4"}},
Body: io.NopCloser(bytes.NewReader(payload)),
ContentLength: int64(len(payload)),
Request: req,
}, nil
})
f.HttpClient = func() (*http.Client, error) {
return &http.Client{
Transport: internaltransport.NewHTTPPolicyRouter(platform, external),
}, nil
}
err := mountAndRun(t, MinutesDownload, []string{
"+download", "--minute-tokens", "tok001", "--output", "out.mp4", "--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("MinutesDownload error = %v, want external route", err)
}
data, err := os.ReadFile("out.mp4")
if err != nil {
t.Fatal(err)
}
if got := string(data); got != "media" {
t.Fatalf("downloaded content = %q, want external payload", got)
}
}
func TestResolveFilenameFromResponse_EmptyDispositionFilename(t *testing.T) {
resp := &http.Response{
Header: http.Header{
@@ -796,7 +742,7 @@ func TestDownload_TypedErr_NetworkTransport_HttpError(t *testing.T) {
Command: "+probe-dl",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
client, err := rctx.Factory.ExternalHTTPClient()
client, err := rctx.Factory.HttpClient()
if err != nil {
return err
}
@@ -899,7 +845,7 @@ func TestDownload_TypedErr_OverwriteProtection(t *testing.T) {
Command: "+probe-overwrite",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
client, err := rctx.Factory.ExternalHTTPClient()
client, err := rctx.Factory.HttpClient()
if err != nil {
return err
}

View File

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

View File

@@ -93,6 +93,15 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
},
{
// The both-axes form has to hold inside a batch too: it is the only
// way to freeze rows AND columns there, since +styles-put (the other
// carrier of a combined freeze) is not a batchable sub-op.
shortcut: "+dim-freeze",
sc: DimFreeze,
args: []string{"--sheet-id", "sh1", "--rows", "1", "--cols", "2"},
subInput: `{"sheet-id":"sh1","rows":1,"cols":2}`,
},
{
shortcut: "+dim-group",
sc: DimGroup,
@@ -763,7 +772,7 @@ func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
{
"+pivot-create summarize_by out of enum",
"+pivot-create",
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
`{"target_sheet_id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
"summarize_by",
},
// +chart-create properties.position.row has minimum:0 — P0

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// TestChartPrintExample pins the --print-example contract: a known type
// prints its template and skips execution entirely; an unknown type lists
// the available ones.
func TestChartPrintExample(t *testing.T) {
t.Parallel()
t.Run("prints template without locator flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
parent, _, _, _ := newTestRig(t, sc)
var buf bytes.Buffer
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
if err := parent.Execute(); err != nil {
t.Fatalf("print-example should run standalone, got: %v", err)
}
if !strings.Contains(buf.String(), `"sectors"`) {
t.Errorf("pie template should carry sectors, got %q", buf.String())
}
})
t.Run("unknown type lists available", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
ve := requireValidation(t, err, `no example for chart type "donut"`)
if !strings.Contains(ve.Message, "pie") {
t.Errorf("message should list available types, got %q", ve.Message)
}
if ve.Param != "--print-example" {
t.Errorf("Param = %q, want %q", ve.Param, "--print-example")
}
})
}
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
// template against the embedded chart-create properties schema — a template
// the CLI itself would reject is worse than none.
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
t.Parallel()
for typ, tmpl := range chartExampleTemplates {
t.Run(typ, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
t.Fatalf("template is not valid JSON: %v", err)
}
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
t.Errorf("template rejected by embedded schema: %v", err)
}
})
}
}
// TestNormalizeChartHexColors_Arrays pins color normalization inside arrays:
// the chart schema uses colorTheme / colorScale / highlight_colors, whose
// values are LISTS of bare hex strings. Recursing without the key context
// dropped the "#" prefix and the server rejected a payload its own schema
// allows.
func TestNormalizeChartHexColors_Arrays(t *testing.T) {
t.Parallel()
in := map[string]interface{}{
"colorTheme": []interface{}{"4472C4", "ED7D31"},
"highlight_colors": []interface{}{"FF0000"},
"colorScale": []interface{}{map[string]interface{}{"color": "70AD47"}},
"backgroundColor": "4472C4",
"colorMode": "auto",
"title": []interface{}{"4472C4"},
}
raw, err := json.Marshal(normalizeChartHexColors(in))
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
theme := got["colorTheme"].([]interface{})
if theme[0] != "#4472C4" || theme[1] != "#ED7D31" {
t.Errorf("colorTheme = %v, want both prefixed", theme)
}
if got["highlight_colors"].([]interface{})[0] != "#FF0000" {
t.Errorf("highlight_colors = %v", got["highlight_colors"])
}
if got["colorScale"].([]interface{})[0].(map[string]interface{})["color"] != "#70AD47" {
t.Errorf("colorScale = %v", got["colorScale"])
}
// Non-hex values under a color-ish key, and hex-looking values under a
// non-color key, must both be left alone.
if got["colorMode"] != "auto" {
t.Errorf("colorMode = %v, want untouched", got["colorMode"])
}
if got["title"].([]interface{})[0] != "4472C4" {
t.Errorf("title = %v, want untouched (not a color key)", got["title"])
}
}

View File

@@ -22,10 +22,10 @@ func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
return &common.RuntimeContext{Cmd: cmd}
}
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
// only when it names a real file (a forgotten @), while leaving genuine inline
// content alone — including the case the old name-shape heuristic got wrong:
// prose that merely ends in or mentions a filename.
// TestGuardCSVValueIsNotFilePath covers the existing-file tier: a bare --csv
// value naming a real file is a forgotten "@". The prescription names the fix
// with a <path> placeholder — the untrusted value must not be spliced into
// command-shaped text an agent would copy verbatim.
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
@@ -33,23 +33,98 @@ func TestGuardCSVValueIsNotFilePath(t *testing.T) {
t.Fatal(err)
}
// Bare value naming an existing file → guarded with a fix-it hint.
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
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 !strings.Contains(ve.Message, `"data.csv"`) {
t.Errorf("message should name the offending value as data, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "--csv @<path>") {
t.Errorf("message should prescribe the @ form via placeholder, got: %q", ve.Message)
}
if strings.Contains(ve.Message, "@data.csv") {
t.Errorf("message must not splice the value into a command fragment, got: %q", ve.Message)
}
if ve.Param != "--csv" {
t.Errorf("param = %q, want --csv", ve.Param)
}
}
// TestGuardCSVValueIsNotFilePath_MissingButPathShaped covers the second tier.
// A path that doesn't resolve used to pass through and be written into the
// cell verbatim — a wrong value with a success exit code. The common source is
// an absolute path: `@` rejects those, so the caller drops the `@` and retries.
// Since the file can't be read from cwd, the prescription is stdin.
func TestGuardCSVValueIsNotFilePath_MissingButPathShaped(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// Content that is not a real file must pass through unchanged.
for _, v := range []string{
"改完记得更新config.json", // prose ending in a filename — not a real file
"remember to update data.csv", // mentions the real file but isn't its name
"nope.csv", // relative path from another working directory
"./missing.csv", // explicit relative prefix
"../sibling/x.tsv", // parent-relative
"/tmp/nope.csv", // absolute — the `@`-rejected case
"~/data.tsv", // home-relative
"/var/tmp/export", // no extension, but an unmistakable path prefix
"C:/Users/me/a.csv", // windows-style, still ASCII path shape
} {
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v))
ve := requireValidation(t, err, "looks like a file path")
if !strings.Contains(ve.Hint, "--csv @") || !strings.Contains(ve.Hint, "--csv - <") {
t.Errorf("value %q: hint should offer both @file and stdin, got: %q", v, ve.Hint)
}
// The untrusted value must never appear inside the command-shaped
// hint: "--csv - < $(id).csv" copied by an agent would expand in a
// POSIX shell. The value is only named as quoted data in the message.
if strings.Contains(ve.Hint, v) {
t.Errorf("value %q: hint must not splice the raw value into a command fragment, got: %q", v, ve.Hint)
}
if !strings.Contains(ve.Message, v) {
t.Errorf("value %q: message should still name the offending value, got: %q", v, ve.Message)
}
}
}
// TestGuardCSVValueIsNotFilePath_SkipsResolvedInput pins the origin rule that
// makes the shape heuristic safe: a value that arrived via @file / stdin is
// never inspected, however path-shaped its content — so the hint's promise
// that stdin writes such text verbatim actually holds, and a correct
// `--csv @file` invocation can't be re-rejected for its content.
func TestGuardCSVValueIsNotFilePath_SkipsResolvedInput(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
t.Fatal(err)
}
for _, v := range []string{
"nope.csv", // path-shaped, missing — rejected when inline
"data.csv", // names an existing file — rejected when inline
} {
rctx := newCSVGuardRuntime(v)
common.TestMarkInputResolved(rctx, "csv")
if err := guardCSVValueIsNotFilePath(rctx); err != nil {
t.Errorf("resolved value %q must skip the guard, got: %v", v, err)
}
}
}
// TestGuardCSVValueIsNotFilePath_PassesThrough pins what must still reach the
// sheet untouched. The prose cases are why the guard checks a narrow shape
// instead of "contains a filename": an earlier name-shape heuristic rejected
// them. "N/A" and "README.md" pin the two narrowing rules — a slash alone is
// not a path, and a filename alone is not a CSV path.
func TestGuardCSVValueIsNotFilePath_PassesThrough(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
for _, v := range []string{
"改完记得更新config.json", // CJK prose ending in a filename
"remember to update data.csv", // prose mentioning a file
"a,b\n1,2", // multi-cell CSV
"hello world",
"nope.csv", // path-shaped but no such file
"N/A", // slash, but no CSV extension and no path prefix
"README.md", // filename shape, not a CSV one
"report 2026.csv", // has a space: content, not a path
"",
} {
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -16,7 +16,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Fail-fast by default: the first failure aborts the remaining operations and already-applied sub-operations are NOT rolled back (on \"N succeeded, M failed\" resend only the failed tail, not the whole batch); pass --continue-on-error to keep going past failures; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
@@ -50,7 +50,7 @@ var flagDefs = map[string]commandDef{
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style): `{ top|bottom|left|right|all: {style,weight,color} }`; style = solid|dashed|dotted|double|none, weight = thin|medium|thick (string), color = hex like #000000", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -59,8 +59,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
@@ -72,11 +72,12 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -86,8 +87,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -98,8 +99,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
@@ -115,8 +116,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
@@ -133,10 +134,11 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two); not accepted with `--writes` (each writes item carries its own sheet selector)"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Write range (A1 notation). XOR with `--writes` (single region: --range+--cells; multiple regions: --writes)"},
{Name: "cells", Kind: "own", Type: "string", Required: "xor", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "writes", Kind: "own", Type: "string", Required: "xor", Desc: "Multi-region write as a JSON array (up to 100 items), each `{sheet_name|sheet_id, range, cells}` — the sheet selector LIVES IN EACH ITEM (same convention as +batch-update sub-ops and +styles-put items; the top-level --sheet-name is rejected). cells has the same shape as `--cells` (2D array; per-cell cell_styles/border_styles allowed). The whole array goes out as ONE batched request (fail-fast, no rollback), cross-sheet supported; typical use: fixing formulas scattered across ranges/sheets — do not assemble a +batch-update operations array for this. XOR with `--range`+`--cells`; range-level uniform styling stays with +styles-put afterwards", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
@@ -148,8 +150,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
@@ -161,8 +163,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
@@ -175,7 +177,7 @@ var flagDefs = map[string]commandDef{
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,weight,color}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides. style = line type (solid|dashed|dotted|double|none); weight = thickness (thin|medium|thick — a string, not a pixel number); color = hex like #000000. { all: {...} } sets all four sides at once. This is the only border flag: no --border-all / --border-top / --border-color exist", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -184,8 +186,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -204,9 +206,10 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "print-example", Kind: "own", Type: "string", Required: "optional", Desc: "Print a minimal ready-to-edit --properties template for a chart type (area|bar|column|combo|line|pie|radar|scatter) and exit. Purely local: no locator flags, no network; an unknown type lists the available ones"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
@@ -215,8 +218,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -227,8 +230,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -238,8 +241,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -250,10 +253,10 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"},
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one batched call (fail-fast, no rollback). Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}},
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -264,8 +267,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
@@ -277,8 +280,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -289,8 +292,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -300,8 +303,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
@@ -314,10 +317,11 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet). Optional: when omitted the whole sheet is read (clipped to the actual grid bounds; actual_range in the response names what was read); pair with --max-chars / --output-path on large sheets"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (the cap auto-raises to a bounded 20M chars — the read path is not streaming, this cap is the memory guard; pass an explicit --max-chars for more); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more. Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Note the file is the data payload as JSON — on +csv-get too, where the CSV text sits in a field inside it — not a ready-to-use .csv; redirect stdout instead if you want a bare CSV file. Omit it to print to stdout as usual."},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
@@ -328,8 +332,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{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; 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"},
@@ -342,9 +346,10 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`. XOR with `--ranges`"},
{Name: "ranges", Kind: "own", Type: "string", Required: "xor", Desc: "Multiple row/column ranges to delete as a JSON array (up to 100 items, e.g. `[\"5:5\",\"8:8\",\"11:13\"]` or `[\"C:C\",\"F:G\"]`); rows and columns cannot be mixed, ranges must not overlap; XOR with `--range`. CLI sorts positions in DESCENDING order into one batched delete (fail-fast, no rollback) — ascending deletion would shift later indexes as earlier rows/columns disappear; the CLI handles the ordering", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -354,10 +359,12 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "dimension", Kind: "own", Type: "string", Required: "optional", Desc: "[legacy] Dimension (row or column), paired with --count; sets one axis only and unfreezes the other. Prefer --rows / --cols", Hidden: true, Enum: []string{"row", "column"}},
{Name: "count", Kind: "own", Type: "int", Required: "optional", Desc: "[legacy] Freeze the first N rows/columns (paired with --dimension); 0 clears all freezing. Equivalent to --rows N / --cols N, and only --rows/--cols can hold both axes at once", Hidden: true},
{Name: "rows", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N rows; together with --cols this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen rows)"},
{Name: "cols", Kind: "own", Type: "int", Required: "optional", Desc: "Freeze the first N columns; together with --rows this states the COMPLETE freeze state — an omitted axis is left unfrozen (0 means no frozen columns)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -366,8 +373,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
@@ -379,8 +386,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -390,9 +397,9 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from the preceding row/column) / `after` (from the following row/column). Omit the flag to inherit the following row/column (same as `after`) — the backend cannot leave a new row/column unstyled; for a truly blank row/column, clear formats afterwards with +cells-clear --scope formats. Insertion always lands before `--position`; this only selects which side's style is copied.", Enum: []string{"before", "after"}},
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -403,8 +410,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -415,8 +422,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -427,8 +434,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -448,8 +455,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -459,8 +466,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
@@ -489,8 +496,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -501,8 +508,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -512,8 +519,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -522,8 +529,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -534,8 +541,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"},
@@ -547,8 +554,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -559,8 +566,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -570,8 +577,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
@@ -584,8 +591,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image URI handle returned by the upload flow (not a sheet object reference_id; XOR with `--image-token`); converted to file_token automatically"},
@@ -605,8 +612,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -617,8 +624,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -628,8 +635,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "optional", Desc: "Optional image file_token; mutually exclusive with `--image-uri`; omit both to keep the current image. Common source: `image_token` returned by `+float-image-list`"},
@@ -702,8 +709,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -714,8 +721,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -725,8 +732,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -737,8 +744,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
@@ -751,8 +758,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
@@ -764,8 +771,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
@@ -777,8 +784,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
@@ -798,10 +805,10 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"},
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one batched call (fail-fast, no rollback). Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}},
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -812,8 +819,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -837,8 +844,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -848,8 +855,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -858,8 +865,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -868,8 +875,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -880,8 +887,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -892,8 +899,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -903,8 +910,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -914,8 +921,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -924,8 +931,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -934,8 +941,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -945,8 +952,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -957,8 +964,8 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -968,13 +975,22 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id — required: pass this or `--sheet-name` (exactly one of the two)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name — required: pass this or `--sheet-id` (exactly one of the two)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+styles-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (target sheets are named inside --styles items)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "styles", Kind: "own", Type: "string", Required: "required", Desc: "Visual spec JSON applied to an EXISTING spreadsheet: top-level `{styles:[...]}`, one item per target sheet (`name` is the real sheet name), each giving at least one of `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze`. The vocabulary is identical to `--styles` on `+workbook-create` / `+table-put` (cell_styles = A1 range + flat style fields, borders via the `border` shorthand {style,weight,color} applied to all four sides — border_styles only for per-side differences; row/col sizes = row/column range + size in px — type only for standard/auto; merges = cell range; freeze = `{rows:N, cols:N}`). The whole spec expands into one batched request (fail-fast, no rollback: applied sub-operations stay); ranges may target any region of the sheet", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the batched request template for each expanded operation; no network side effects"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
@@ -983,6 +999,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (cap auto-raises to a bounded 20M chars; explicit --max-chars overrides). Passing 0 means \"no cap of my own\" and resolves to the same ceiling as leaving the flag alone (500000, or the offload limit with --output-path) — never down to the tool's smaller omitted-value fallback.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap auto-raises to a bounded offload default (20M chars)** rather than unlimited — the read path is not streaming, so this cap is the memory guard; an explicit --max-chars overrides it. The stdout receipt reports `complete` (and `truncated` plus a warning when the cap was hit), so check it instead of assuming the file holds the whole sheet. Omit it to print to stdout as usual."},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},

View File

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

View File

@@ -38,9 +38,134 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
chainFlagAliases(cmd)
}
}
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
//
// Eval traces show unknown-flag failures cluster on a handful of habitual
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
// the enum-normalization contract above: a name whose value semantics are
// identical to the real flag is rewritten silently (zero round-trips); a
// name whose fix changes the value or moves it into a JSON field gets a
// curated prescription on the unknown-flag error instead — never a silent
// rewrite.
// commandFlagAliases maps, per command, habitual flag names onto the flag
// actually registered. Only pairs with identical value semantics belong
// here: the rewrite is invisible, so it must be safe to apply unread
// (+csv-put --file with a path value still trips the file-path guard, which
// prescribes @file / stdin).
var commandFlagAliases = map[string]map[string]string{
"+csv-put": {"file": "csv"},
"+sheet-create": {"name": "title"},
// The new name is the only name-valued input a rename takes, so the
// habitual spellings are unambiguous (unlike +sheet-copy, where a name
// could mean the copy's title or the source selector and gets a
// prescription instead). 07-28 root-cause report #25: 10/10 wrote
// --new-name, 24 occurrences.
"+sheet-rename": {"name": "title", "new-name": "title"},
// size → width/height: the styles protocol (--styles row_sizes/col_sizes)
// spells the pixel dimension "size", and pre-2026-07 batches accepted it
// here too — the rename is the single largest sub-op error cluster in
// eval traces (15+ hits). Same pixel-count semantics, safe to rewrite.
"+cols-resize": {"cols": "range", "size": "width"},
"+rows-resize": {"rows": "range", "size": "height"},
"+range-fill": {"source": "source-range", "target": "target-range"},
"+range-copy": {"source": "source-range", "target": "target-range"},
"+range-move": {"source": "source-range", "target": "target-range"},
}
// intuitiveFlagHints carries the prescription for habitual names whose fix
// is not a 1:1 rename — the value belongs to a different flag or to a field
// inside a JSON payload. The hint spells the exact correct form so the
// retry needs no --help round trip.
var intuitiveFlagHints = map[string]map[string]string{
"+sheet-copy": {
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
},
"+dim-insert": {
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
},
// Must prescribe --rows / --cols, never the retired --dimension/--count
// pair (DEPRECATED(phase-2) on dimFreezeLegacyNote): those flags are hidden
// from --help, so they do not even appear in the "valid flags" list printed
// beside this hint, and using them earns a second note steering back here.
"+dim-freeze": {
"frozen-rows": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
"frozen-cols": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
"frozen-columns": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
"frozen-row-count": "freeze the first N rows with --rows N (add --cols M to hold columns too — one call states the whole freeze state)",
"frozen-col-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
"frozen-column-count": "freeze the first N columns with --cols N (add --rows M to hold rows too — one call states the whole freeze state)",
},
"+cells-set-style": {
"bold": "use --font-weight bold",
"italic": "use --font-style italic",
"underline": "use --font-line underline",
"font-bold": "use --font-weight bold",
"bg-color": "use --background-color",
// Google Sheets API vocabulary (wrapStrategy).
"wrap-strategy": "use --word-wrap (overflow / auto-wrap / word-clip)",
// The border family: the only border flag is --border-styles (composite
// JSON); color and per-side variants ride inside it.
"border-style": `borders take one composite flag: --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right, or "all" for all four)`,
"border-color": `border color rides inside --border-styles JSON, e.g. --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}'`,
"border-all": `use --border-styles '{"all":{"style":"solid","weight":"thin","color":"#000000"}}' — the "all" key applies one spec to all four sides`,
"border-top": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}'`,
"border-bottom": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}'`,
"border-left": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"left":{"style":"solid","weight":"thin","color":"#000000"}}'`,
"border-right": `per-side borders ride inside --border-styles JSON, e.g. --border-styles '{"right":{"style":"solid","weight":"thin","color":"#000000"}}'`,
},
"+cells-set": {
// Predictable prior from +table-put --styles: models will try to
// attach range-level styling to a --writes call the same way.
"styles": `range-level styling goes through +styles-put (same {"styles":[...]} vocabulary); per-cell styles ride inside the cells objects as cell_styles`,
// +workbook-create's untyped-data flag, carried over to the write
// command (07-28 root-cause report #9, 63 occurrences; values↔cells
// shares no prefix so edit distance never suggests the fix).
"values": `cell contents go in --cells as a 2D array of cell objects ('[[{"value":…},…],…]'); --values is +workbook-create's flag for untyped initial data`,
},
"+table-put": {
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
},
}
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
// (on top of any hook a prior PostMount installed, e.g. --token →
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
// canonical name), and the command's intuitive-alias table. Either way a
// habitual name parses as the real flag with zero round trips. Aliases
// never shadow a registered flag and never appear in --help; an alias whose
// target vanished (spec-side rename) is dropped, degrading to the
// unknown-flag prescription.
func chainFlagAliases(cmd *cobra.Command) {
aliases := commandFlagAliases[cmd.Name()]
usable := make(map[string]string, len(aliases))
for alias, target := range aliases {
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
usable[alias] = target
}
}
prev := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if strings.Contains(name, "_") {
name = strings.ReplaceAll(name, "_", "-")
}
if target, ok := usable[name]; ok {
name = target
}
return prev(fs, name)
})
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
@@ -50,6 +175,19 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
// immediately.
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagFromParseError(ferr)
// Targeted fix for a high-frequency agent mistake: +batch-update carries no
// top-level sheet locator (each sub-op names its own sheet inside its input),
// yet agents reach for --sheet-id / --sheet-name at the top level. An
// edit-distance suggestion would only mislead here, so skip it and name the
// real contract instead. Underscore spellings (--sheet_id) are matched too:
// the error message itself teaches the underscore key names, and sub-op
// inputs accept them, so agents mix the two styles.
locatorName := strings.ReplaceAll(name, "_", "-")
if isUnknown && c.Name() == "+batch-update" && (locatorName == "sheet-id" || locatorName == "sheet-name") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"batch-update has no top-level sheet locator; put sheet_id/sheet_name inside each operation's input").
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag"})
}
if !isUnknown {
return common.ValidationErrorf("%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
@@ -67,6 +205,21 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
strings.Join(suggestions, ", "), list)
}
}
// A curated prescription beats both: it spells the exact correct form
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
// Edit-distance candidates are dropped with it — they can contradict the
// prescription (--font-bold ranked --font-color/--font-line/--font-size
// while the fix is --font-weight), and a machine-readable suggestion that
// disagrees with the hint sends agents down the wrong retry.
// The map is keyed hyphenated but the parse error reports the flag as
// typed, so --frozen_rows must hit the same entry as --frozen-rows.
if rx, ok := intuitiveFlagHints[c.Name()][strings.ReplaceAll(name, "_", "-")]; ok {
hint = rx
if list := inlineFlagList(valid); list != "" {
hint = rx + "; valid flags: " + list
}
suggestions = nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
@@ -139,6 +292,69 @@ var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
// from the API docs; lowercased by canonicalEnumValue before lookup.
"merge_all": "all",
"merge_rows": "rows",
"merge_columns": "columns",
// Boolean-style word-wrap habits: true unambiguously means wrap on;
// false means "don't wrap", whose Lark default is overflow (word-clip is
// a distinct truncation mode nobody spells "false").
"true": "auto-wrap",
"false": "overflow",
// Google Sheets wrapStrategy vocabulary: WRAP / CLIP / OVERFLOW. Only
// the first two need mapping — overflow is spelled the same in both.
"wrap": "auto-wrap",
"clip": "word-clip",
}
// DEPRECATED(phase-2): enum values this CLI used to accept and now expresses
// by omitting the flag. They are dropped from the published enum so the docs
// and --help stop teaching them, but a caller that still passes one must not
// hard-fail: the value was valid — for --inherit-style it was even the
// DEFAULT — so existing scripts and any agent carrying older docs would break
// on a spelling that never meant anything else.
//
// Semantics: a retired value is cleared, making the call identical to omitting
// the flag (pinned by TestRetiredEnumValueMatchesOmitted). It is deliberately
// silent — unlike --dimension/--count there is nothing for the caller to
// migrate to, so a note would only be noise.
//
// Phase 2 removal: drop the entry here and let the normal enum error apply.
var retiredEnumValues = map[string]map[string][]string{
// +dim-insert --inherit-style dropped "none" when the side mapping was
// corrected: no inheritance is what omitting the flag already means, so
// the value was pure redundancy.
"+dim-insert": {"inherit-style": {"none"}},
}
// clearRetiredFlag makes a retired value indistinguishable from an absent
// flag. Resetting Changed matters as much as the value: the batch path
// expresses "as if omitted" by deleting the key, so Changed() reports false
// there. Leaving cobra's Changed at true would make a flag whose logic reads
// Changed() (rather than the value) behave differently standalone than inside
// +batch-update — and TestBatchOp_BodyMatchesStandalone only catches such a
// split once it reaches the request body.
func clearRetiredFlag(cmd *cobra.Command, name string) {
_ = cmd.Flags().Set(name, "")
if f := cmd.Flags().Lookup(name); f != nil {
f.Changed = false
}
}
// isRetiredEnumValue reports whether val is a retired spelling for this
// command's flag, i.e. one that should be cleared rather than rejected.
func isRetiredEnumValue(command, flag, val string) bool {
byFlag, ok := retiredEnumValues[command]
if !ok {
return false
}
for _, retired := range byFlag[flag] {
if strings.EqualFold(retired, val) {
return true
}
}
return false
}
// canonicalEnumValue returns the enum entry an off-vocabulary value
@@ -225,6 +441,10 @@ func chainEnumNormalization(cmd *cobra.Command) {
c.Flags().Set(df.Name, canon)
continue
}
if isRetiredEnumValue(cmd.Name(), df.Name, val) {
clearRetiredFlag(c, df.Name)
continue
}
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
val, df.Name, strings.Join(df.Enum, ", ")).
WithParam("--" + df.Name)

View File

@@ -96,6 +96,54 @@ func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
}
}
// TestSheetsFlagErrorFunc_BatchUpdateSheetLocator pins the targeted fix: a
// top-level --sheet-id / --sheet-name on +batch-update points the caller at
// the per-op locator contract instead of offering a misleading fuzzy guess.
func TestSheetsFlagErrorFunc_BatchUpdateSheetLocator(t *testing.T) {
t.Parallel()
for _, name := range []string{"sheet-id", "sheet-name", "sheet_id", "sheet_name"} {
t.Run(name, func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --"+name))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, "put sheet_id/sheet_name inside each operation's input") {
t.Errorf("message should name the per-op locator contract, got %q", verr.Message)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("must not offer a fuzzy guess here, got hint %q", verr.Hint)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--"+name {
t.Errorf("Params should carry the offending flag, got %v", verr.Params)
}
if len(verr.Params[0].Suggestions) != 0 {
t.Errorf("no suggestions expected, got %v", verr.Params[0].Suggestions)
}
})
}
}
// TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests confirms the
// special case is scoped to the two sheet-locator flags: any other unknown
// flag on +batch-update keeps the normal did-you-mean behaviour.
func TestSheetsFlagErrorFunc_BatchUpdateOtherUnknownStillSuggests(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+batch-update"}
c.Flags().String("operations", "", "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --operation"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if strings.Contains(verr.Message, "no top-level sheet locator") {
t.Errorf("non-locator unknown flag must not hit the special case, got %q", verr.Message)
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
@@ -284,9 +332,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--col-size", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
ve := requireValidation(t, err, `unknown flag "--col-size"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
@@ -294,3 +342,289 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
}
})
}
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
// habitual name with identical value semantics parses as the real flag on a
// mounted command, costing zero round trips (eval: --cols, --file, --name,
// --source/--target each burned an unknown-flag failure plus a --help call).
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
t.Parallel()
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--width", "100",
"--dry-run",
})
if err != nil {
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
}
if !strings.Contains(stdout, "A:D") {
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
}
})
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-create")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--name", "汇总",
"--dry-run",
})
if err != nil {
t.Fatalf("--name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "汇总") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("sheet-rename --new-name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-rename")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--new-name", "授权需求清单",
"--dry-run",
})
if err != nil {
t.Fatalf("--new-name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "授权需求清单") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+range-fill")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--source", "B2",
"--target", "B3:B10",
"--dry-run",
})
if err != nil {
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
}
for _, want := range []string{"B2", "B3:B10"} {
if !strings.Contains(stdout, want) {
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
}
}
})
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+csv-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--start-cell", "A1",
"--file", "a,b\n1,2",
"--dry-run",
})
if err != nil {
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
}
if !strings.Contains(stdout, "a,b") {
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
}
})
t.Run("cols-resize --size parses as --width", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A:C",
"--size", "120",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --width (styles-protocol vocabulary), got: %v", err)
}
if !strings.Contains(stdout, "120") {
t.Errorf("dry-run body should carry the pixel width 120, got %q", stdout)
}
})
t.Run("rows-resize --size parses as --height", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+rows-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "1:3",
"--size", "36",
"--dry-run",
})
if err != nil {
t.Fatalf("--size should alias to --height (styles-protocol vocabulary), got: %v", err)
}
})
t.Run("alias never shadows a registered flag", func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+csv-put"}
c.Flags().String("csv", "", "")
c.Flags().String("file", "", "") // hypothetical real flag wins
chainFlagAliases(c)
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
t.Fatalf("parse: %v", err)
}
if got, _ := c.Flags().GetString("file"); got != "x" {
t.Errorf("registered --file should keep its own value, got %q", got)
}
if got, _ := c.Flags().GetString("csv"); got != "" {
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
}
})
}
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
// names whose fix is not a rename answer with the exact correct form, so the
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
// --help calls, +dim-insert kept failing even after reading help).
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
t.Parallel()
cases := []struct {
command string
args []string
wrong string
wantHint []string
// rejectHint pins what a prescription must NOT name — used where the
// obvious wording would steer into a deprecated flag.
rejectHint []string
}{
{
command: "+dim-insert",
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
wrong: "--dimension",
wantHint: []string{"--position", "--count"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
wrong: "--frozen-rows",
// Must prescribe the CURRENT spelling: --dimension/--count is
// retired and hidden from --help, so a hint naming it would point at
// a flag missing from the same error's valid-flags list.
wantHint: []string{"--rows N"},
rejectHint: []string{"--dimension", "--count"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
wrong: "--bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+sheet-copy",
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
wrong: "--new-sheet-name",
wantHint: []string{"--title", "source sheet"},
},
{
command: "+table-put",
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
wrong: "--start-cell",
wantHint: []string{`"start_cell"`, "+csv-put"},
},
{
command: "+cells-set",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--values", `[["x"]]`},
wrong: "--values",
wantHint: []string{"--cells", "+workbook-create"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-row-count", "1"},
wrong: "--frozen-row-count",
wantHint: []string{"--rows N"},
rejectHint: []string{"--dimension", "--count"},
},
{
// The parse error reports the flag as typed: the underscore
// spelling must hit the same curated entry as the hyphenated one.
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen_rows", "2"},
wrong: "--frozen_rows",
wantHint: []string{"--rows N"},
rejectHint: []string{"--dimension", "--count"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--font-bold", "true"},
wrong: "--font-bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bg-color", "#FFF"},
wrong: "--bg-color",
wantHint: []string{"--background-color"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--wrap-strategy", "overflow"},
wrong: "--wrap-strategy",
wantHint: []string{"--word-wrap"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-all", "thin"},
wrong: "--border-all",
wantHint: []string{"--border-styles", `"all"`},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-top", "thin"},
wrong: "--border-top",
wantHint: []string{"--border-styles", `"top"`},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--border-color", "#000"},
wrong: "--border-color",
wantHint: []string{"--border-styles", "color"},
},
}
for _, tc := range cases {
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, tc.command)
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
for _, want := range tc.wantHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
// The valid-flags list is appended to the same Hint, so only the
// prescription itself is checked for banned wording.
prescription, _, _ := strings.Cut(ve.Hint, "; valid flags:")
for _, banned := range tc.rejectHint {
if strings.Contains(prescription, banned) {
t.Errorf("prescription must not steer to %q, got %q", banned, prescription)
}
}
// A curated prescription must not ship contradicting edit-distance
// candidates (--font-bold used to carry --font-color/--font-line/
// --font-size in params while the fix is --font-weight).
for _, p := range ve.Params {
if len(p.Suggestions) > 0 {
t.Errorf("curated prescription should drop edit-distance suggestions, got %v", p.Suggestions)
}
}
})
}
}

View File

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

View File

@@ -204,3 +204,109 @@ func keysOf(m map[string]interface{}) []string {
}
return out
}
// TestPrintSchema_DottedPathSlicing covers --flag-name's dotted-path form,
// which had no tests at all: disabling the implicit items/oneOf descent, or the
// explicit "items" segment, broke nothing.
//
// The feature exists so agents can pull one subtree out of chart-create's
// ~1,750-line properties schema instead of paging the whole dump (SKILL.md
// points at it by name). A silent regression pushes them straight back to full
// dumps, which is invisible in any output-correctness test.
func TestPrintSchema_DottedPathSlicing(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+chart-create")
decode := func(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var node map[string]interface{}
if err := json.Unmarshal(raw, &node); err != nil {
t.Fatalf("schema slice is not a JSON object: %v", err)
}
return node
}
props := func(t *testing.T, node map[string]interface{}) map[string]interface{} {
t.Helper()
p, ok := node["properties"].(map[string]interface{})
if !ok {
t.Fatalf("node has no properties: %v", node)
}
return p
}
t.Run("one segment walks into properties", func(t *testing.T) {
t.Parallel()
raw, err := print("properties.snapshot")
if err != nil {
t.Fatalf("slice failed: %v", err)
}
if _, has := props(t, decode(t, raw))["plotArea"]; !has {
t.Errorf("snapshot subtree should expose plotArea, got %s", raw)
}
})
t.Run("array levels are descended implicitly", func(t *testing.T) {
t.Parallel()
// data.refs is an array; naming the field must land on the ITEM shape,
// not force the caller to spell ".items".
raw, err := print("properties.snapshot.data.refs")
if err != nil {
t.Fatalf("slice failed: %v", err)
}
node := decode(t, raw)
if node["type"] != "array" {
t.Errorf("refs should still be the array node, got %v", node["type"])
}
deeper, err := print("properties.snapshot.data.refs.value")
if err != nil {
t.Fatalf("descending through array items failed: %v", err)
}
if len(deeper) == 0 {
t.Error("expected the item's value field")
}
})
t.Run("an explicit items segment also works", func(t *testing.T) {
t.Parallel()
if _, err := print("properties.snapshot.plotArea.axes.items"); err != nil {
t.Fatalf("explicit items segment failed: %v", err)
}
})
t.Run("a slice is strictly smaller than the whole flag schema", func(t *testing.T) {
t.Parallel()
full, err := print("properties")
if err != nil {
t.Fatalf("full dump failed: %v", err)
}
slice, err := print("properties.snapshot.plotArea.axes")
if err != nil {
t.Fatalf("slice failed: %v", err)
}
if len(slice) >= len(full) {
t.Errorf("slice is %d bytes vs %d for the full schema — slicing saves nothing", len(slice), len(full))
}
})
t.Run("a miss names the keys actually available", func(t *testing.T) {
t.Parallel()
_, err := print("properties.snapshot.nope")
if err == nil {
t.Fatal("want an error for an unknown segment")
}
ve := requireValidation(t, err, `no "nope" under properties.snapshot`)
if !strings.Contains(ve.Message, "plotArea") {
t.Errorf("the miss must list the reachable keys so the caller can retry without a full dump, got %q", ve.Message)
}
if ve.Param != "--flag-name" {
t.Errorf("param = %q, want --flag-name", ve.Param)
}
})
t.Run("the underscore spelling of the flag still resolves", func(t *testing.T) {
t.Parallel()
if _, err := printFlagSchemaFor("+cells-set-style")("border_styles"); err != nil {
t.Fatalf("underscore flag name should resolve to border-styles: %v", err)
}
})
}

View File

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

View File

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

View File

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

View File

@@ -333,6 +333,12 @@ func (m *mapFlagView) normalizeAndValidateEnums() error {
m.raw[rawKey] = canonical
continue
}
// A retired value means "as if omitted" — delete the key so Changed()
// also reports it as absent, matching the standalone path.
if isRetiredEnumValue(m.command, df.Name, value) {
delete(m.raw, rawKey)
continue
}
message := fmt.Sprintf("invalid value %q for --%s, allowed: %s", value, df.Name, strings.Join(df.Enum, ", "))
if match := closestEnumValue(value, df.Enum); match != "" {
message += fmt.Sprintf("; did you mean %q?", match)

View File

@@ -11,7 +11,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
neturl "net/url"
"strings"
@@ -320,7 +319,12 @@ func requireSheetSelector(sheetID, sheetName string) error {
sheetID = strings.TrimSpace(sheetID)
sheetName = strings.TrimSpace(sheetName)
if sheetID == "" && sheetName == "" {
// Eval traces show every occurrence recovering on the next call, so
// the gap is knowing WHICH name to pass, not that one is needed: a
// just-created workbook has a single sheet named Sheet1, and any
// other workbook needs one +workbook-info lookup.
return common.ValidationErrorf("specify at least one of --sheet-id or --sheet-name").
WithHint("a freshly created workbook has one sheet named Sheet1 (`--sheet-name Sheet1`); otherwise list the real sheets with `lark-cli sheets +workbook-info --url <URL>`").
WithParams(
sheetsInvalidParam("sheet-id", "required; specify at least one"),
sheetsInvalidParam("sheet-name", "required; specify at least one"),
@@ -425,6 +429,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Unambiguous habitual shapes are rewritten onto the wire contract
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
// so both the standalone cobra path and +batch-update sub-ops (whose
// mapFlagView.Str re-encodes composites through here) get the rewrite.
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
out = norm(out)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
// hooks fill in flat-flag-derived fields) and any flag without an
@@ -435,6 +446,134 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
return out, nil
}
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
// input shapes onto the wire contract before schema validation — same
// contract as enum normalization: only a shape whose meaning is beyond
// doubt may be rewritten; anything ambiguous must fail with a prescription
// instead. Applied to the parsed JSON value inside parseJSONFlag.
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
"+cells-set": {"cells": normalizeCellsFlagValue},
"+cells-set-style": {"border-styles": normalizeBorderStylesFlagValue},
"+cells-batch-set-style": {"border-styles": normalizeBorderStylesFlagValue},
"+chart-create": {"properties": normalizeChartHexColors},
"+chart-update": {"properties": normalizeChartHexColors},
}
// normalizeChartHexColors walks a chart properties payload and prefixes bare
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
// Excel-habit form the chart backend rejects with "expected rgba() or
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
// bare hex color is untouched.
func normalizeChartHexColors(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
t[k] = "#" + s
continue
}
// A color key can hold an ARRAY of colors (colorTheme, series
// palettes). Recursing without the key would lose the color
// context and leave bare hex strings unprefixed, so the server
// rejects a payload the schema itself allows.
if arr, ok := val.([]interface{}); ok && isColorKey(k) {
normalizeChartHexColorList(arr)
continue
}
normalizeChartHexColors(val)
}
case []interface{}:
for _, e := range t {
normalizeChartHexColors(e)
}
}
return v
}
// normalizeChartHexColorList prefixes bare hex strings inside an array that
// sits under a color key, and keeps descending for nested shapes.
func normalizeChartHexColorList(arr []interface{}) {
for i, e := range arr {
if s, ok := e.(string); ok {
if isBareHexColor(s) {
arr[i] = "#" + s
}
continue
}
if nested, ok := e.([]interface{}); ok {
normalizeChartHexColorList(nested)
continue
}
normalizeChartHexColors(e)
}
}
// isColorKey reports whether a key names a color (or a list of colors). The
// value gate is isBareHexColor — a strict 6/8-digit hex check — so matching a
// key generously is safe: a non-hex value under a color-ish key is left alone.
// Plural and color-prefixed forms matter because the chart schema uses
// colorTheme / colorScale / colorGradient / highlight_colors, none of which
// end in "color".
func isColorKey(k string) bool {
if k == "color" || k == "colors" {
return true
}
for _, suffix := range []string{"_color", "Color", "_colors", "Colors"} {
if strings.HasSuffix(k, suffix) {
return true
}
}
return strings.HasPrefix(k, "color") || strings.HasPrefix(k, "Color")
}
func isBareHexColor(s string) bool {
if len(s) != 6 && len(s) != 8 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// cellObjectKeys pins the property vocabulary of a single cell in the
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
var cellObjectKeys = map[string]struct{}{
"border_styles": {},
"cell_styles": {},
"data_validation": {},
"formula": {},
"multiple_values": {},
"note": {},
"rich_text": {},
"value": {},
}
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
// --cells contract expects. Eval traces show agents writing a single cell
// routinely pass {"value":…} without the two array layers; when every key
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
// row or a column) is returned untouched for the schema validator to
// prescribe.
func wrapLoneCellObject(v interface{}) interface{} {
obj, ok := v.(map[string]interface{})
if !ok || len(obj) == 0 {
return v
}
for k := range obj {
if _, known := cellObjectKeys[k]; !known {
return v
}
}
return []interface{}{[]interface{}{obj}}
}
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
@@ -451,6 +590,51 @@ func requireJSONObject(runtime flagView, name string) (map[string]interface{}, e
return m, nil
}
// ─── aggregated sub-error rendering ────────────────────────────────────
//
// Several flags collect per-item failures and fold them into ONE typed error
// (--styles, --writes, --operations). A Problem carries a single Hint slot,
// so the naive fold — taking only each inner error's Message — silently drops
// the very prescriptions this domain adds (requireSheetSelector's
// "+workbook-info" pointer, the batch key contract). These two helpers keep
// them: a lone failure hands its Hint to the outer error's Hint field, and a
// folded list inlines each hint next to its own message.
// aggregatedIssueParts splits a collected sub-error into its message and its
// hint ("" when it carries none), unwrapping the typed Problem so the message
// is the bare text rather than the Error() rendering.
func aggregatedIssueParts(err error) (msg, hint string) {
if p, ok := errs.ProblemOf(err); ok {
return p.Message, p.Hint
}
return err.Error(), ""
}
// aggregatedIssueText renders one collected sub-error for a folded, multi-issue
// message, appending its hint in parentheses so a per-item prescription is not
// lost to the single shared Hint slot.
func aggregatedIssueText(err error) string {
msg, hint := aggregatedIssueParts(err)
if hint == "" {
return msg
}
return msg + " (" + hint + ")"
}
// prefixValidationIssue re-labels a collected sub-error with the path it was
// found at ("--writes[2]"), keeping its Hint. Formatting the inner error into
// a new message with "%v" would drop that hint on the floor — the collectors
// only ever read Message and Hint, so the two must stay separate all the way
// to the fold.
func prefixValidationIssue(path string, err error) error {
msg, hint := aggregatedIssueParts(err)
out := common.ValidationErrorf("%s: %s", path, msg).WithCause(err)
if hint != "" {
out = out.WithHint("%s", hint)
}
return out
}
// requireJSONArray is parseJSONFlag + a type assertion to []interface{}.
func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
@@ -466,146 +650,3 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
}
return a, nil
}
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 12 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
style := map[string]interface{}{}
if v := runtime.Str("background-color"); v != "" {
style["background_color"] = v
}
if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v
}
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size")
}
if v := runtime.Str("font-style"); v != "" {
style["font_style"] = v
}
if v := runtime.Str("font-weight"); v != "" {
style["font_weight"] = v
}
if v := runtime.Str("font-line"); v != "" {
style["font_line"] = v
}
if v := runtime.Str("horizontal-alignment"); v != "" {
style["horizontal_alignment"] = v
}
if v := runtime.Str("vertical-alignment"); v != "" {
style["vertical_alignment"] = v
}
if v := runtime.Str("word-wrap"); v != "" {
style["word_wrap"] = v
}
if v := runtime.Str("number-format"); v != "" {
style["number_format"] = v
}
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
}
v, err := parseJSONFlag(runtime, "border-styles")
if err != nil {
return nil, err
}
m, ok := v.(map[string]interface{})
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
return m, nil
}
// requireAnyStyleFlag ensures at least one style-defining flag (style or
// border) is set — otherwise the request would do nothing.
func requireAnyStyleFlag(runtime flagView) error {
if len(buildCellStyleFromFlags(runtime)) > 0 {
return nil
}
if runtime.Str("border-styles") != "" {
return nil
}
return common.ValidationErrorf("at least one style flag is required (e.g. --background-color, --font-weight, --border-styles)").
WithParams(
sheetsInvalidParam("background-color", "required; specify at least one style flag"),
sheetsInvalidParam("font-weight", "required; specify at least one style flag"),
sheetsInvalidParam("border-styles", "required; specify at least one style flag"),
)
}

View File

@@ -144,6 +144,13 @@ func TestSheetHelpersValidationMetadata(t *testing.T) {
if validationErr.Params[0].Name != "--sheet-id" || validationErr.Params[1].Name != "--sheet-name" {
t.Fatalf("params = %#v, want --sheet-id/--sheet-name", validationErr.Params)
}
// Eval traces recover on the very next call, so the missing piece is
// which name to pass — the hint has to name Sheet1 and the lookup.
for _, want := range []string{"Sheet1", "+workbook-info"} {
if !strings.Contains(validationErr.Hint, want) {
t.Errorf("hint should mention %q, got %q", want, validationErr.Hint)
}
}
})
t.Run("spreadsheet url shape reports url param", func(t *testing.T) {
@@ -224,6 +231,19 @@ func parseDryRunAPI(t *testing.T, sc common.Shortcut, args []string) []interface
return calls
}
// dryRunWarning returns the advisory text a dry-run surfaces under
// data.warning_message, or "" when the shortcut emitted none.
func dryRunWarning(t *testing.T, sc common.Shortcut, args []string) string {
t.Helper()
out, err := runShortcut(t, sc, append(args, "--dry-run"))
if err != nil {
t.Fatalf("dry-run failed: %v\noutput=%s", err, out)
}
data, _ := decodeDryRunRaw(t, out)["data"].(map[string]interface{})
warning, _ := data["warning_message"].(string)
return warning
}
func decodeDryRunRaw(t *testing.T, out string) map[string]interface{} {
t.Helper()
idx := strings.Index(out, "{")

View File

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

View File

@@ -5,6 +5,7 @@ package sheets
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -29,10 +30,14 @@ import (
// The tool's contract (post-translation):
// { excel_id, operations: [{tool_name, input}, ...], continue_on_error? }
//
// continue_on_error defaults to false (strict transaction): any failure
// rolls back the whole batch. CLI leaves the default in place for the
// three "fan-out" shortcuts since they're meant to be all-or-nothing;
// only +batch-update lets callers flip it via --continue-on-error.
// continue_on_error defaults to false (fail-fast): execution stops at the
// first failing sub-op, but sub-ops already applied are NOT rolled back —
// the server reports "N succeeded, M failed" and the N stay in the sheet
// (verified against live batches; earlier docs wrongly promised a rollback,
// which made agents resend whole batches and double-apply the successes).
// CLI leaves the default in place for the fan-out shortcuts since they're
// idempotent stamps; only +batch-update lets callers flip it via
// --continue-on-error.
// BatchUpdate accepts a CLI-shape operations array (each item
// {shortcut, input}); on Validate / DryRun / Execute we translate each
@@ -42,7 +47,7 @@ import (
var BatchUpdate = common.Shortcut{
Service: "sheets",
Command: "+batch-update",
Description: "Execute a batch of write shortcuts as a single atomic request (rolls back on failure by default).",
Description: "Execute a batch of write shortcuts in one request; fail-fast on the first failing sub-op (already-applied sub-ops are NOT rolled back).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -64,7 +69,11 @@ var BatchUpdate = common.Shortcut{
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := batchUpdateInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
dr := invokeToolDryRun(token, ToolKindWrite, "batch_update", input)
if warnings := batchWarnings(runtime); len(warnings) > 0 {
dr.Set("warning_message", strings.Join(warnings, "\n"))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
@@ -75,6 +84,9 @@ var BatchUpdate = common.Shortcut{
if err != nil {
return err
}
for _, w := range batchWarnings(runtime) {
fmt.Fprintln(runtime.IO().ErrOut, w)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
@@ -83,7 +95,8 @@ var BatchUpdate = common.Shortcut{
return nil
},
Tips: []string{
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
"high-risk-write: preview with --dry-run, get the user's explicit consent, then re-run with --yes appended — do not pass --yes before the user has confirmed (without it the call exits 10 asking for confirmation).",
"Execution is fail-fast, NOT transactional: on \"N succeeded, M failed\" the succeeded sub-ops stay applied (no rollback) — fix the failure and resend ONLY the operations from the first failed index onward; resending the whole batch re-applies the succeeded ones. Pass --continue-on-error to keep going past failures instead.",
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
},
}
@@ -124,6 +137,171 @@ func batchUpdateInput(runtime *common.RuntimeContext, token string) (map[string]
return input, nil
}
// batchNeedsDimInsertBeforeStyleWarning reports whether any +dim-insert sub-op
// requests --inherit-style before at the first row/column, where the
// preceding-side style cannot be copied (no preceding row/column exists).
// batchWarnings collects the advisory notes a batch surfaces before it runs,
// in one place so DryRun and Execute cannot drift apart on which ones they
// report.
func batchWarnings(runtime *common.RuntimeContext) []string {
var out []string
if batchNeedsDimInsertBeforeStyleWarning(runtime) {
out = append(out, dimInsertBeforeStyleWarning)
}
out = append(out, batchCollidingDimFreezeNotes(runtime)...)
return append(out, batchLegacyDimFreezeNotes(runtime)...)
}
// batchCollidingDimFreezeNotes reports +dim-freeze sub-ops that target the SAME
// sheet more than once. Freeze is full-state replacement, so each of them
// discards the previous one and only the last survives — both still report
// success, which is exactly why the mistake goes unnoticed. The CLI has already
// walked the whole ops array by this point, so it can name the survivor and the
// single sub-op that holds everything the caller clearly meant to hold.
//
// A batch cannot read current state, and +styles-put (the other combined-freeze
// carrier) is not batchable, so folding into ONE sub-op is the only fix — hence
// a note rather than a suggestion to reorder.
func batchCollidingDimFreezeNotes(runtime *common.RuntimeContext) []string {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {
return nil // a malformed --operations is the translator's to report.
}
type freezeOp struct {
index int
rows, cols int
}
// Keyed by the sub-op's sheet selector: freezes on different sheets are
// independent. Order of first appearance keeps the notes deterministic.
bySheet := map[string][]freezeOp{}
var order []string
for i, raw := range rawOps {
op, ok := raw.(map[string]interface{})
if !ok {
continue
}
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
continue
}
input, _ := op["input"].(map[string]interface{})
if input == nil {
continue
}
fv := newMapFlagViewForCommand("+dim-freeze", input)
rows, cols, ok := dimFreezeAxes(fv)
if !ok {
continue // an unusable sub-op is the translator's to report.
}
key := strings.TrimSpace(fv.Str("sheet-id")) + "\x00" + strings.TrimSpace(fv.Str("sheet-name"))
if _, seen := bySheet[key]; !seen {
order = append(order, key)
}
bySheet[key] = append(bySheet[key], freezeOp{index: i, rows: rows, cols: cols})
}
var notes []string
for _, key := range order {
ops := bySheet[key]
if len(ops) < 2 {
continue
}
indexes := make([]string, 0, len(ops))
// The combined state is what the caller almost certainly meant: keep the
// last positive value named for each axis. An axis nobody ever freezes
// stays 0, so a deliberate "unfreeze everything" batch still renders as
// --rows 0 --cols 0 rather than inventing a freeze.
combinedRows, combinedCols := 0, 0
for _, op := range ops {
indexes = append(indexes, fmt.Sprintf("operations[%d]", op.index))
if op.rows > 0 {
combinedRows = op.rows
}
if op.cols > 0 {
combinedCols = op.cols
}
}
last := ops[len(ops)-1]
notes = append(notes, fmt.Sprintf(
"warning: %s are all +dim-freeze on the same sheet — freeze replaces the WHOLE state, so each one discards the previous and only %s survives (ending at %s). They all report success. Replace them with ONE sub-op: %s",
strings.Join(indexes, ", "),
indexes[len(indexes)-1],
dimFreezeSpelling(last.rows, last.cols),
dimFreezeSpelling(combinedRows, combinedCols)))
}
return notes
}
// batchLegacyDimFreezeNotes steers +dim-freeze sub-ops still written in the
// deprecated --dimension/--count form (see DEPRECATED(phase-2) on
// dimFreezeLegacyNote). The standalone command prints that note from its own
// DryRun/Execute, which a sub-op never reaches — yet the batch is where the
// legacy form does the most damage: freeze is full-state replacement, so two
// per-axis sub-ops both report success while only the last axis stays frozen,
// and +styles-put (the other way to set both axes) is not batchable. The
// wording comes from the shared helper, so it cannot drift from the standalone
// one.
func batchLegacyDimFreezeNotes(runtime *common.RuntimeContext) []string {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {
return nil // a malformed --operations is the translator's to report.
}
var notes []string
for i, raw := range rawOps {
op, ok := raw.(map[string]interface{})
if !ok {
continue
}
if sc, _ := op["shortcut"].(string); sc != "+dim-freeze" {
continue
}
input, _ := op["input"].(map[string]interface{})
if input == nil {
continue
}
if note := dimFreezeLegacyNote(newMapFlagViewForCommand("+dim-freeze", input)); note != "" {
notes = append(notes, fmt.Sprintf("operations[%d] (+dim-freeze): %s", i, note))
}
}
return notes
}
func batchNeedsDimInsertBeforeStyleWarning(runtime *common.RuntimeContext) bool {
rawOps, err := parseBatchOperationsFlag(runtime)
if err != nil {
return false
}
for _, raw := range rawOps {
op, ok := raw.(map[string]interface{})
if !ok {
continue
}
sc, _ := op["shortcut"].(string)
if sc != "+dim-insert" {
continue
}
input, _ := op["input"].(map[string]interface{})
isBefore := false
for _, key := range []string{"inherit-style", "inherit_style", "inheritStyle"} {
if v, _ := input[key].(string); strings.EqualFold(v, "before") {
isBefore = true
break
}
}
if !isBefore {
continue
}
posRaw, hasPos := input["position"]
if !hasPos {
continue
}
// Warn only at the first row/column (idx 0).
if _, idx, err := parseA1Position(strings.TrimSpace(fmt.Sprintf("%v", posRaw))); err == nil && idx == 0 {
return true
}
}
return false
}
// parseBatchOperationsFlag accepts --operations as either a JSON array (the
// operations list directly) or an envelope object { operations, continue_on_error }
// for back-compat with the legacy --data shape. Returns the operations array.
@@ -154,12 +332,17 @@ func parseBatchOperationsFlag(runtime *common.RuntimeContext) ([]interface{}, er
var CellsBatchSetStyle = common.Shortcut{
Service: "sheets",
Command: "+cells-batch-set-style",
Description: "Apply one style block to many sheet-prefixed ranges in one atomic batch.",
Description: "Apply one style block to many sheet-prefixed ranges in one batch request (fail-fast, no rollback).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-set-style"),
Tips: []string{
"DEPRECATED: superseded by +styles-put, whose one spec also covers merges, row/col sizes and freeze — prefer it for new work.",
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err
@@ -189,6 +372,14 @@ var CellsBatchSetStyle = common.Shortcut{
if err != nil {
return err
}
// DEPRECATED(phase-2): +cells-batch-set-style — replaced by +styles-put.
// Phase 1 (here): the command keeps working and is already retired from
// the skill docs via bundle.json doc_hidden_shortcuts in
// sheet-skill-spec; steer new usage to the superset in-band.
// Phase 2 removal: drop the shortcut from spec-tables + its
// doc_hidden_shortcuts entry, then this command and its input builder.
fmt.Fprintln(runtime.IO().ErrOut,
"note: +cells-batch-set-style is superseded by +styles-put (one spec covers styles + merges + row/col sizes + freeze); prefer +styles-put for new work")
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", input)
if err != nil {
return err
@@ -230,7 +421,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
@@ -258,7 +449,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
var CellsBatchClear = common.Shortcut{
Service: "sheets",
Command: "+cells-batch-clear",
Description: "Clear content/formats across many sheet-prefixed ranges in one atomic batch (irreversible).",
Description: "Clear content/formats across many sheet-prefixed ranges in one batch request (irreversible; fail-fast, no rollback).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -334,7 +525,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
var DropdownUpdate = common.Shortcut{
Service: "sheets",
Command: "+dropdown-update",
Description: "Install or replace one dropdown across many sheet-prefixed ranges atomically.",
Description: "Install or replace one dropdown across many sheet-prefixed ranges in one batch request (fail-fast, no rollback).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -380,7 +571,7 @@ var DropdownUpdate = common.Shortcut{
var DropdownDelete = common.Shortcut{
Service: "sheets",
Command: "+dropdown-delete",
Description: "Clear dropdowns from many sheet-prefixed ranges atomically (irreversible).",
Description: "Clear dropdowns from many sheet-prefixed ranges in one batch request (irreversible; fail-fast, no rollback).",
Risk: "high-risk-write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -452,7 +643,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
if err := checkBatchStampBudget("ranges", totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype)
@@ -484,10 +675,10 @@ const maxBatchRanges = 100
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
// summing past it. totalCells is int64 to stay overflow-safe.
func checkBatchStampBudget(totalCells int64) error {
func checkBatchStampBudget(flagName string, totalCells int64) error {
if totalCells > maxStampMatrixCells {
return sheetsValidationForFlag("ranges",
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
return sheetsValidationForFlag(flagName,
"the request expands to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
totalCells, maxStampMatrixCells)
}
return nil

View File

@@ -58,6 +58,39 @@ func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
}
}
func TestBatchUpdate_DimInsertInheritAfterCopiesFollowingStyle(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"D","count":1,"inherit_style":"after"}}
]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
op := ops[0].(map[string]interface{})
if op["tool_name"] != "modify_sheet_structure" {
t.Fatalf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
}
in, _ := op["input"].(map[string]interface{})
// inherit_style=after copies the following column's style via a plain
// before-insert at the same position (the backend anchors on the following
// column), so position stays D with side=before.
assertInputEquals(t, in, map[string]interface{}{
"excel_id": testToken,
"sheet_id": "sh1",
"operation": "insert",
"position": "D",
"count": float64(1),
"side": "before",
})
}
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
@@ -405,6 +438,21 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
wantMatch: "'input' must be a JSON object",
},
{
name: "wrapped cell_styles structure",
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_styles":{"background_color":"#EBF1F8"}}}]`,
wantMatch: "do not wrap in cell_styles",
},
{
name: "wrapped styles structure",
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","styles":{"font_weight":"bold"}}}]`,
wantMatch: "do not wrap in styles",
},
{
name: "wrapped cell_merges structure",
opsJSON: `[{"shortcut":"+cells-set-style","input":{"sheet_name":"s","range":"A1","cell_merges":[{"range":"A1:B1"}]}}]`,
wantMatch: "do not wrap in cell_merges",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -420,6 +468,99 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
}
}
// TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper guards the
// wrapped-structure rejection against overreach: the same style fields in
// their correct flattened form must translate cleanly — only the wrapper
// container keys (cell_styles / styles / cell_merges) are rejected.
func TestBatchUpdate_FlattenedStyleKeysNotMistakenForWrapper(t *testing.T) {
t.Parallel()
got, err := translateBatchOp(map[string]interface{}{
"shortcut": "+cells-set-style",
"input": map[string]interface{}{
"sheet_name": "s",
"range": "A1",
"background_color": "#EBF1F8",
"font_weight": "bold",
},
}, testToken, 0)
if err != nil {
t.Fatalf("flattened style keys must pass the wrapper check, got %v", err)
}
input := got["input"].(map[string]interface{})
cells := input["cells"].([][]interface{})
style := cells[0][0].(map[string]interface{})["cell_styles"].(map[string]interface{})
if style["background_color"] != "#EBF1F8" || style["font_weight"] != "bold" {
t.Fatalf("translated style = %#v", style)
}
}
// TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags locks the static
// assumption wrappedSubOpInputKeys relies on: no shortcut registered in
// batchOpDispatch declares a flag named cell_styles / cell_merges / styles.
// If a future dispatch-table addition (e.g. +table-put) carries one of these
// flags, its legitimate input would be silently rejected by the wrapper
// check — this test turns that silent breakage into a build-time failure.
func TestBatchUpdate_WrapperKeysDisjointFromSubOpFlags(t *testing.T) {
t.Parallel()
wrapped := make(map[string]struct{}, len(wrappedSubOpInputKeys))
for _, k := range wrappedSubOpInputKeys {
wrapped[k] = struct{}{}
}
for shortcut := range batchOpDispatch {
for _, f := range flagsFor(shortcut) {
key := strings.ReplaceAll(f.Name, "-", "_")
if _, clash := wrapped[key]; clash {
t.Errorf("%s declares flag --%s which collides with wrappedSubOpInputKeys; "+
"exempt this shortcut from the wrapper check before adding it to batchOpDispatch",
shortcut, f.Name)
}
}
}
}
// TestBatchUpdate_AggregatesMultipleOpErrors pins op-level aggregation: when
// several operations are invalid, one reply names them all (numbered, with
// each op's own error) instead of failing on the first bad op only. A single
// bad op keeps the historical single-error message (no aggregate wrapper).
func TestBatchUpdate_AggregatesMultipleOpErrors(t *testing.T) {
t.Parallel()
t.Run("two bad ops reported together", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set-magic","input":{}},
{"shortcut":"+cells-set","input":{"sheet_name":"s","range":"A1"}},
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
]`,
"--yes", "--dry-run",
})
requireValidation(t, err, "2 of 3 operations failed validation")
for _, want := range []string{"1) ", "2) ", "operations[0]", "operations[1]"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("aggregated op error should contain %q, got %q", want, err.Error())
}
}
})
t.Run("single bad op keeps plain message", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set-magic","input":{}},
{"shortcut":"+cells-clear","input":{"sheet_name":"s","range":"A1"}}
]`,
"--yes", "--dry-run",
})
requireValidation(t, err, "not allowed in +batch-update")
if strings.Contains(err.Error(), "operations failed validation") {
t.Errorf("single bad op must not get the aggregate wrapper, got %q", err.Error())
}
})
}
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
// highest-frequency batch failures, so an agent can repair its payload in a
// single retry without --help / --print-schema round trips.
@@ -588,3 +729,145 @@ func TestSplitSheetPrefixedRange(t *testing.T) {
// Compile-time use of json import
_ = json.Marshal
}
// TestBatchUpdate_CollidingDimFreezeWarns covers the failure mode the legacy
// deprecation note alone could not surface: two +dim-freeze sub-ops on one
// sheet. Freeze is full-state replacement, so the second silently discards the
// first — and BOTH report success, which is why it goes unnoticed. Per-op
// "equivalent to --rows 1" / "equivalent to --cols 2" notes do not say that;
// the caller has to infer the interaction. This pins that the CLI states it.
func TestBatchUpdate_CollidingDimFreezeWarns(t *testing.T) {
t.Parallel()
t.Run("two per-axis freezes on one sheet", func(t *testing.T) {
t.Parallel()
warning := dryRunWarning(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1}},
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","cols":2}}
]`,
"--yes",
})
for _, want := range []string{
"operations[0], operations[1]",
"only operations[1] survives",
"--cols 2)", // the state actually reached
"ONE sub-op: --rows 1 --cols 2", // the fix
} {
if !strings.Contains(warning, want) {
t.Errorf("collision warning should contain %q, got %q", want, warning)
}
}
})
t.Run("legacy spelling collides the same way and keeps its own note", func(t *testing.T) {
t.Parallel()
warning := dryRunWarning(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","dimension":"row","count":1}},
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","dimension":"column","count":2}}
]`,
"--yes",
})
if !strings.Contains(warning, "ONE sub-op: --rows 1 --cols 2") {
t.Errorf("legacy spelling should collide too, got %q", warning)
}
if !strings.Contains(warning, "superseded by --rows/--cols") {
t.Errorf("per-op deprecation note should still ride along, got %q", warning)
}
})
t.Run("different sheets do not collide", func(t *testing.T) {
t.Parallel()
warning := dryRunWarning(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1}},
{"shortcut":"+dim-freeze","input":{"sheet_name":"S2","cols":2}}
]`,
"--yes",
})
if strings.Contains(warning, "same sheet") {
t.Errorf("freezes on different sheets are independent, got %q", warning)
}
})
t.Run("a single freeze warns about nothing", func(t *testing.T) {
t.Parallel()
warning := dryRunWarning(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_name":"S1","rows":1,"cols":2}}]`,
"--yes",
})
if warning != "" {
t.Errorf("one combined freeze is the correct form, got warning %q", warning)
}
})
}
// TestBatchOpAliasCollidesWithTarget pins the message for a sub-op carrying
// BOTH an intuitive alias and the flag it aliases. The key is recognized, so
// reporting it as "unknown input key" (which it did, because keys are walked
// in sorted order and "size" sorts before "width", leaving nothing to conflict
// with yet) sent the caller looking for a typo that was not there.
func TestBatchOpAliasCollidesWithTarget(t *testing.T) {
t.Parallel()
t.Run("conflicting values name both spellings", func(t *testing.T) {
t.Parallel()
input := map[string]interface{}{"sheet_name": "S1", "range": "A:C", "size": float64(100), "width": float64(120)}
err := normalizeSubOpInputKeys("+cols-resize", input)
if err == nil {
t.Fatal("want an error for size + width with different values")
}
for _, want := range []string{`"size"`, `"width"`, "same flag"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should contain %q, got %q", want, err.Error())
}
}
if strings.Contains(err.Error(), "unknown input key") {
t.Errorf("an aliased key is not unknown, got %q", err.Error())
}
})
t.Run("same value under both spellings drops the alias", func(t *testing.T) {
t.Parallel()
input := map[string]interface{}{"sheet_name": "S1", "range": "A:C", "size": float64(120), "width": float64(120)}
if err := normalizeSubOpInputKeys("+cols-resize", input); err != nil {
t.Fatalf("identical values are harmless, got %v", err)
}
if _, still := input["size"]; still {
t.Errorf("the alias should be dropped, got %#v", input)
}
if input["width"] != float64(120) {
t.Errorf("width = %#v, want 120", input["width"])
}
})
}
// TestBatchUpdate_AggregatedErrorsKeepHints pins that folding several bad
// sub-ops into one message does not cost the caller the per-shortcut key
// contract each single-op error carries — otherwise the more mistakes you
// make, the less guidance you get.
func TestBatchUpdate_AggregatedErrorsKeepHints(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL, "--yes",
"--operations", `[
{"shortcut":"+cells-set","input":{"sheet_name":"S1","bogus":1}},
{"shortcut":"+cells-clear","input":{"sheet_name":"S1","nope":2}}
]`,
})
ve := requireValidation(t, err, "2 of 2 operations failed validation")
for _, want := range []string{
"+cells-set input keys:",
"+cells-clear input keys:",
} {
if !strings.Contains(ve.Message, want) {
t.Errorf("aggregated message should inline %q, got %q", want, ve.Message)
}
}
}

View File

@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write — always preview with --dry-run; clear is not undoable.",
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
},
}
@@ -242,7 +242,7 @@ func mergeInput(runtime flagView, token, sheetID, sheetName, op string, withMerg
var RowsResize = common.Shortcut{
Service: "sheets",
Command: "+rows-resize",
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one atomic call, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
Description: "Resize rows in pixels: --range + --height <px> for one uniform height, --heights '{\"1\":50,\"2:20\":30,\"21\":\"auto\"}' for per-row heights in one batch request, or --type standard/auto (--range is 1-based A1 like \"2:10\" or \"5\").",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -260,15 +260,19 @@ var RowsResize = common.Shortcut{
var ColsResize = common.Shortcut{
Service: "sheets",
Command: "+cols-resize",
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one atomic call, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
Description: "Resize columns in pixels (NOT Excel char units): --range + --width <px> for one uniform width, --widths '{\"A\":100,\"C:E\":120}' for per-column widths in one batch request, or --type standard to reset (--range is column letters like \"A:E\" or \"C\"; no auto for cols).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cols-resize"),
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
Tips: []string{
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
`Different widths per column in one batch request: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
},
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
}
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall

View File

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

View File

@@ -34,6 +34,65 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
},
},
{
name: "+cells-get include=formula without style pins include_styles=false",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "formula"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"include_styles": false,
"value_render_option": "formula",
"cell_limit": float64(unboundedReadLimit),
},
},
{
// --include truncation toggles include_truncation_info so the tool
// estimates and returns per-cell isRowTruncated / isColTruncated.
name: "+cells-get include=truncation",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"include_styles": false,
"include_truncation_info": true,
"cell_limit": float64(unboundedReadLimit),
},
},
{
// --output-path alone raises the cap to the bounded file-offload
// default — NOT the unbounded sentinel; the read path is not
// streaming, so the cap is the OOM guard.
name: "+cells-get output-path uses bounded offload cap",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--output-path", "out.json"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"max_chars": float64(outputPathReadLimit),
},
},
{
// An explicit --max-chars survives --output-path instead of being
// silently replaced by the unbounded sentinel.
name: "+cells-get explicit max-chars survives output-path",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--output-path", "out.json", "--max-chars", "12345"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"max_chars": float64(12345),
},
},
{
// Canonical form: --sheet-id + bare --range. Aligned with
// +cells-get / +csv-get; before the e2e BUG-019 fix this
@@ -92,7 +151,9 @@ func TestDropdownGet_RequiresSheetSelector(t *testing.T) {
// TestReadData_RequiresRange covers the trim-based --range guard on the
// single-range readers (--range "" slips past cobra's MarkFlagRequired but
// must still be rejected by Validate).
// must still be rejected by Validate). +csv-get is deliberately absent:
// its --range is optional — omitted/blank means a whole-sheet read (see
// TestCsvGet_RangeOptionalDefaultsToFullSheet).
func TestReadData_RequiresRange(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -100,7 +161,6 @@ func TestReadData_RequiresRange(t *testing.T) {
sc common.Shortcut
}{
{"+cells-get", CellsGet},
{"+csv-get", CsvGet},
{"+dropdown-get", DropdownGet},
}
for _, c := range cases {
@@ -114,6 +174,23 @@ func TestReadData_RequiresRange(t *testing.T) {
}
}
// TestCsvGet_RangeOptionalDefaultsToFullSheet pins the whole-sheet default:
// with --range omitted the request carries the over-wide clip range, so a
// full read needs no workbook-info pre-flight (eval: --range was the most
// missed required flag on +csv-get once the rest of the surface settled).
func TestCsvGet_RangeOptionalDefaultsToFullSheet(t *testing.T) {
t.Parallel()
stdout, _, err := runShortcutCapturingErr(t, CsvGet, []string{
"--url", testURL, "--sheet-id", testSheetID, "--dry-run",
})
if err != nil {
t.Fatalf("rangeless +csv-get must pass validation, got: %v", err)
}
if !strings.Contains(stdout, csvGetFullSheetRange) {
t.Fatalf("dry-run body should carry the full-sheet range %q, got %q", csvGetFullSheetRange, stdout)
}
}
// TestInfoTypeFromInclude exercises the fine-grained → coarse mapping
// directly (white-box).
func TestInfoTypeFromInclude(t *testing.T) {

View File

@@ -6,6 +6,7 @@ package sheets
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
@@ -128,12 +129,29 @@ var DimInsert = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-insert"),
Validate: validateViaInput(dimInsertInput),
Tips: []string{
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
},
Validate: validateViaInput(dimInsertInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimInsertInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
switch {
case dimInsertNeedsBeforeStyleWarning(runtime):
dr.Set("warning_message", dimInsertBeforeStyleWarning)
case dimInsertAnchorShifted(runtime, input):
// --inherit-style before anchors one unit earlier (see
// dimInsertInput), so the previewed body carries a position the
// caller never typed. Unexplained, that reads as an off-by-one bug in
// exactly the artifact people dry-run to check for off-by-one bugs.
dr.Set("warning_message", fmt.Sprintf(
"note: the previewed position is %q, not the %q you passed — this is not an off-by-one. --inherit-style before is emulated by anchoring one row/column earlier and inserting after it, which lands in the same place while copying the PRECEDING style. The row/column still appears at %q.",
input["position"], strings.TrimSpace(runtime.Str("position")), strings.TrimSpace(runtime.Str("position"))))
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
@@ -148,6 +166,9 @@ var DimInsert = common.Shortcut{
if err != nil {
return err
}
if dimInsertNeedsBeforeStyleWarning(runtime) {
fmt.Fprintln(runtime.IO().ErrOut, dimInsertBeforeStyleWarning)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
@@ -157,8 +178,41 @@ var DimInsert = common.Shortcut{
},
}
// dimInsertBeforeStyleWarning fires only when the preceding-side style cannot
// be copied: --inherit-style before at the first row/column, where no
// preceding row/column exists. The row/column is still inserted before
// --position, just without style inheritance. (--inherit-style after has no
// such edge — a plain before-insert always has a following row/column.)
const dimInsertBeforeStyleWarning = "warning: --inherit-style before cannot copy the preceding row/column's style at the first row/column (no preceding row/column exists); inserting before --position without style inheritance. Copy styles separately if needed."
// dimInsertAnchorShifted reports whether the built body carries an anchor
// position different from the one the caller passed — true exactly when the
// --inherit-style before emulation moved it back one unit. Compared against the
// built input rather than recomputed, so the note can never claim a shift the
// request does not have.
func dimInsertAnchorShifted(runtime flagView, input map[string]interface{}) bool {
built, ok := input["position"].(string)
return ok && built != strings.TrimSpace(runtime.Str("position"))
}
func dimInsertNeedsBeforeStyleWarning(runtime flagView) bool {
if !runtime.Changed("inherit-style") || runtime.Str("inherit-style") != "before" {
return false
}
// Only the first row/column (idx 0) has no preceding row/column.
_, idx, err := parseA1Position(strings.TrimSpace(runtime.Str("position")))
return err == nil && idx == 0
}
// dimInsertInput passes --position (1-based row number "3" or column letter
// "C") straight to the tool's `position` field; --count maps to `count`.
// "C") to the tool's `position` field; --count maps to `count`.
//
// +dim-insert's public contract is always "insert before --position";
// --inherit-style only selects which side's style the new row/column copies,
// never the insertion side. The sheet-ai tool always copies the *anchor*
// column's style (the target passed as position), regardless of side — so
// --inherit-style before is emulated by anchoring one unit earlier. See the
// switch below.
func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
@@ -184,11 +238,36 @@ func dimInsertInput(runtime flagView, token, sheetID, sheetName string) (map[str
"count": count,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
// --inherit-style selects which side's style the blank row/column copies;
// the insertion always lands *before* --position. Empirically the addCol
// backend copies the *anchor* column's style (the target passed as
// position), regardless of side — side only decides whether the blank lands
// before or after that anchor (verified live, see
// TestDimInsertInheritStyleSideMapping):
// after → side=before at P: the blank lands at P and anchor P becomes the
// *following* neighbour, so the blank copies it. Position unchanged.
// before → side=after at P-1: the blank still lands at P (insert-after-(P-1)
// == insert-before-P) and anchor P-1 becomes the *preceding*
// neighbour, so the blank copies it.
//
// The flag documents `after` as its default, and the omitted case takes that
// branch rather than leaving `side` off the request. This is belt-and-braces,
// not a fix: the backend's own default IS `before`, verified live 07-31 on a
// 4-way sheet (omitted / after / before / no-side-at-all all place the blank
// at --position, and omitted inherits the FOLLOWING row's style exactly as
// `after` does). Sending it explicitly just stops the documented default from
// depending on an undocumented server-side one.
// Pinned by TestDimInsertOmittedMatchesAfter.
switch runtime.Str("inherit-style") {
case "before":
if prev, ok := a1PositionBefore(position); ok {
input["side"] = "after"
input["position"] = prev
}
// First row/column: no preceding row/column exists, so fall back to a
// plain before-insert (dimInsertNeedsBeforeStyleWarning surfaces this).
default: // "after", and the omitted case it is the default for.
input["side"] = "before"
case "after":
input["side"] = "after"
}
return input, nil
}
@@ -203,10 +282,34 @@ var DimDelete = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-delete"),
Validate: validateDimRangeOp("delete"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("ranges") {
if runtime.Changed("range") {
return sheetsValidationForFlag("ranges", "--range and --ranges are mutually exclusive; put every range into --ranges")
}
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return err
}
return validateDimRangeOp("delete")(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
if runtime.Changed("ranges") {
ops, _ := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
}
input, _ := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
},
@@ -219,6 +322,21 @@ var DimDelete = common.Shortcut{
if err != nil {
return err
}
if runtime.Changed("ranges") {
ops, err := dimDeleteRangesOps(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
input, err := dimRangeOpInput(runtime, token, sheetID, sheetName, "delete")
if err != nil {
return err
@@ -232,9 +350,76 @@ var DimDelete = common.Shortcut{
},
Tips: []string{
"Row/column deletion is irreversible. Always preview with --dry-run first.",
`Scattered ranges: --ranges '["5:5","8:8","11:13"]' deletes them in one batch request (fail-fast, no rollback) — the CLI orders positions descending, so indexes never shift under you.`,
},
}
// dimDeleteRangesOps parses --ranges into one atomic batch of
// modify_sheet_structure delete ops, ordered DESCENDING by start position:
// deleting an earlier row shifts every later index up, so ascending
// execution deletes the wrong rows — the recurring failure of hand-built
// dim-delete batches in eval traces. Same-dimension and non-overlap are
// enforced up front.
func dimDeleteRangesOps(runtime flagView, token, sheetID, sheetName string) ([]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
raw, err := requireJSONArray(runtime, "ranges")
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, sheetsValidationForFlag("ranges", "--ranges must be a non-empty JSON array")
}
if len(raw) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(raw))
}
type span struct {
raw string
start, end int
}
spans := make([]span, 0, len(raw))
dimension := ""
for i, v := range raw {
s, ok := v.(string)
if !ok {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] must be a string", i)
}
dim, start, end, err := parseA1Range(s)
if err != nil {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q: %v", i, s, err)
}
if dimension == "" {
dimension = dim
} else if dim != dimension {
return nil, sheetsValidationForFlag("ranges", "--ranges[%d] %q is a %s range but earlier entries are %s ranges; one call deletes rows OR columns, not both", i, s, dim, dimension)
}
spans = append(spans, span{raw: strings.TrimSpace(s), start: start, end: end})
}
sort.Slice(spans, func(i, j int) bool { return spans[i].start > spans[j].start })
for i := 1; i < len(spans); i++ {
// Descending order: spans[i-1] starts at or after spans[i]. Overlap
// (or duplicate) makes the later delete hit already-shifted positions.
if spans[i].end >= spans[i-1].start {
return nil, sheetsValidationForFlag("ranges", "--ranges entries %q and %q overlap; merge them into one range", spans[i].raw, spans[i-1].raw)
}
}
ops := make([]interface{}, 0, len(spans))
for _, sp := range spans {
input := map[string]interface{}{
"excel_id": token,
"operation": "delete",
"range": sp.raw,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
ops = append(ops, map[string]interface{}{
"tool_name": "modify_sheet_structure",
"input": input,
})
}
return ops, nil
}
// validateDimRangeOp returns a Validate closure that delegates to
// dimRangeOpInput for shortcuts (delete/hide/unhide) whose builder takes an
// extra `op` argument. Token check happens here; the rest is the builder.
@@ -281,23 +466,37 @@ var DimUngroup = newDimGroupShortcut(
"+dim-ungroup", "Remove a row/column outline group.", "ungroup",
)
// DimFreeze freezes the first N rows or columns; --count 0 unfreezes that
// dimension.
// DimFreeze sets the sheet's freeze state. Freeze is full-state replacement
// server-side (verified 07-31 live), so every call states the WHOLE state:
// --rows/--cols name both axes at once, while the older --dimension/--count
// pair can only name one and therefore unfreezes the other.
var DimFreeze = common.Shortcut{
Service: "sheets",
Command: "+dim-freeze",
Description: "Freeze the first N rows or columns; --count 0 unfreezes the chosen dimension.",
Description: "Freeze the first N rows and/or columns; this sets the whole freeze state, so an axis you do not name ends up unfrozen.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Validate: validateViaInput(dimFreezeInput),
Tips: []string{
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --rows 1 --cols 2 (holds the header row and the first 2 columns in one call)",
"Freezing is not additive: --dimension row --count 1 followed by --dimension column --count 2 leaves ONLY the columns frozen. Pass --rows/--cols together instead of calling twice",
"To unfreeze one axis but keep the other, state the survivor: --rows 0 --cols 2. Bare --count 0 clears both",
},
Validate: validateViaInput(dimFreezeInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := dimFreezeInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
dr := invokeToolDryRun(token, ToolKindWrite, "modify_sheet_structure", input)
// Surface the deprecation steer during the preview too: agents dry-run
// before executing, so a note only on the execute path arrives after the
// spelling is already committed to.
if note := dimFreezeLegacyNote(runtime); note != "" {
dr.Set("warning_message", note)
}
return dr
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
@@ -312,6 +511,9 @@ var DimFreeze = common.Shortcut{
if err != nil {
return err
}
if note := dimFreezeLegacyNote(runtime); note != "" {
fmt.Fprintln(runtime.IO().ErrOut, note)
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "modify_sheet_structure", input)
if err != nil {
return err
@@ -321,33 +523,151 @@ var DimFreeze = common.Shortcut{
},
}
// DEPRECATED(phase-2): +dim-freeze --dimension / --count — replaced by
// --rows / --cols. Phase 1: the flags keep working, are retired from the skill
// docs via bundle.json doc_hidden_flags in sheet-skill-spec and from --help via
// their hidden mark, and every use is steered by dimFreezeLegacyNote.
// Phase 2 removal: drop both rows from spec-tables/flags.json + their
// doc_hidden_flags entry, then dimFreezeLegacyNote, dimFreezeEquivalent, their
// call sites (this shortcut's DryRun/Execute and batchLegacyDimFreezeNotes) and
// the legacy branch in dimFreezeInput.
//
// The pair is a strict subset of --rows/--cols — every --dimension/--count call
// has a byte-identical --rows/--cols spelling (TestDimFreezeEquivalent pins
// this) — and it is the form that reads as if it scoped to one axis when the
// backend replaces the whole freeze state.
//
// dimFreezeLegacyNote returns "" for the modern form. It takes a flagView
// rather than a RuntimeContext so +batch-update can render the identical
// wording for a sub-op (see batchLegacyDimFreezeNotes).
func dimFreezeLegacyNote(runtime flagView) string {
if !runtime.Changed("dimension") && !runtime.Changed("count") {
return ""
}
return fmt.Sprintf(
"note: --dimension/--count is superseded by --rows/--cols, which state both axes at once; this call is equivalent to %s",
dimFreezeEquivalent(runtime))
}
// dimFreezeEquivalent renders the --rows/--cols spelling of a legacy
// --dimension/--count call, so the deprecation note carries the exact
// replacement instead of a generic pointer.
func dimFreezeEquivalent(runtime flagView) string {
rows, cols, _ := dimFreezeAxes(runtime)
return dimFreezeSpelling(rows, cols)
}
// dimFreezeAxes maps either request form onto the (rows, cols) freeze state it
// asks for. Pure mapping, no validation — dimFreezeInput validates first and
// then calls this, so the request body, the deprecation note and the batch
// collision note can never disagree about what a call means. ok is false when
// the flags name no state at all, or when the legacy pair is half-given
// (--count without --dimension); dimFreezeInput reports both.
func dimFreezeAxes(runtime flagView) (rows, cols int, ok bool) {
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
switch {
case axisForm && !pairForm:
return runtime.Int("rows"), runtime.Int("cols"), true
case pairForm && !axisForm:
if !runtime.Changed("dimension") || !runtime.Changed("count") {
return 0, 0, false
}
// A zero count clears BOTH axes — it is the bare unfreeze operation,
// which carries no dimension.
if count := runtime.Int("count"); count > 0 {
if runtime.Str("dimension") == "row" {
return count, 0, true
}
return 0, count, true
}
return 0, 0, true
}
return 0, 0, false
}
// dimFreezeSpelling renders a freeze state as the --rows/--cols flags that
// produce it. Single source of the replacement wording, shared by the
// deprecation note and the batch collision note.
func dimFreezeSpelling(rows, cols int) string {
switch {
case rows > 0 && cols > 0:
return fmt.Sprintf("--rows %d --cols %d", rows, cols)
case rows > 0:
return fmt.Sprintf("--rows %d", rows)
case cols > 0:
return fmt.Sprintf("--cols %d", cols)
}
return "--rows 0 --cols 0"
}
// dimFreezeInput builds the freeze body for both the standalone shortcut and
// the +batch-update sub-op, so the two stay byte-identical (see
// TestBatchOp_BodyMatchesStandalone).
//
// Two request forms, deliberately not mixable:
//
// - --rows / --cols state the complete target state in ONE operation. This
// is the only form that can hold both axes, because freeze is full-state
// replacement server-side (verified 07-31 live: freeze rows=1 then
// columns=2 in two calls ends at 0 rows / 2 columns — the second call
// drops the first axis). It is also the only form usable inside
// +batch-update, whose sub-ops are a static array that cannot read the
// current state to preserve an axis.
// - --dimension + --count is the original single-axis form, kept for
// compatibility. It necessarily unfreezes the axis it does not name.
func dimFreezeInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
if !runtime.Changed("dimension") {
return nil, sheetsValidationForFlag("dimension", "--dimension is required")
pairForm := runtime.Changed("dimension") || runtime.Changed("count")
axisForm := runtime.Changed("rows") || runtime.Changed("cols")
switch {
case pairForm && axisForm:
return nil, sheetsValidationForFlag("rows",
"give either --rows/--cols or --dimension/--count, not both — they are two ways to say the same thing; --rows/--cols is the one that can hold both axes at once")
case !pairForm && !axisForm:
// Prescribes only --rows/--cols: --dimension/--count is retired
// (DEPRECATED(phase-2)) and steering a caller into it here would earn
// them a deprecation note on the very next call.
return nil, sheetsValidationForFlag("rows",
"nothing to freeze: pass --rows N and/or --cols N — e.g. --rows 1 holds the header row, --rows 1 --cols 2 holds it plus the first 2 columns, --rows 0 --cols 0 unfreezes everything")
}
if !runtime.Changed("count") {
return nil, sheetsValidationForFlag("count", "--count is required (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
}
dim := runtime.Str("dimension")
count := runtime.Int("count")
op := "freeze"
if count == 0 {
op = "unfreeze"
}
input := map[string]interface{}{"excel_id": token, "operation": op}
sheetSelectorForToolInput(input, sheetID, sheetName)
if op == "freeze" {
if dim == "row" {
input["freeze_rows"] = count
} else {
input["freeze_columns"] = count
if axisForm {
for _, name := range []string{"rows", "cols"} {
if runtime.Changed(name) && runtime.Int(name) < 0 {
return nil, sheetsValidationForFlag(name, "--%s must be >= 0 (0 leaves that axis unfrozen)", name)
}
}
} else {
if !runtime.Changed("dimension") {
return nil, sheetsValidationForFlag("dimension", "--dimension is required alongside --count (or use --rows/--cols to set both axes at once)")
}
if !runtime.Changed("count") {
return nil, sheetsValidationForFlag("count", "--count is required alongside --dimension (0 unfreezes)")
}
if runtime.Int("count") < 0 {
return nil, sheetsValidationForFlag("count", "--count must be >= 0")
}
}
// Validation done; the flags-to-state mapping is dimFreezeAxes', shared with
// the deprecation and collision notes so the three cannot disagree.
rows, cols, _ := dimFreezeAxes(runtime)
// An all-zero target is the bare "unfreeze" operation, which carries no
// dimension and clears everything — the same request the old --count 0
// always sent.
input := map[string]interface{}{"excel_id": token, "operation": "unfreeze"}
if rows > 0 || cols > 0 {
input["operation"] = "freeze"
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if rows > 0 {
input["freeze_rows"] = rows
}
if cols > 0 {
input["freeze_columns"] = cols
}
return input, nil
}
@@ -557,6 +877,23 @@ func columnIndexToLetter(idx int) string {
return string(out)
}
// a1PositionBefore returns the A1 position one unit before s ("6" → "5",
// "C" → "B"), preserving row/column form. ok is false when s is the first
// row/column (row 1 / column A) — no earlier position — or is not a valid A1
// position. Callers validate via parseA1Position first, so in practice ok is
// false only at the first row/column.
func a1PositionBefore(s string) (pos string, ok bool) {
dimension, idx, err := parseA1Position(s)
if err != nil || idx == 0 {
return "", false
}
if dimension == "row" {
// idx is 0-based; the 1-based number one row earlier is idx itself.
return strconv.Itoa(idx), true
}
return columnIndexToLetter(idx - 1), true
}
// ─── +dim-move (native v3 move_dimension, cli_status: cli-only) ──────
//
// Moves a contiguous block of rows or columns to a new index in the same

View File

@@ -4,6 +4,8 @@
package sheets
import (
"encoding/json"
"reflect"
"strings"
"testing"
@@ -48,6 +50,8 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
},
},
{
// --inherit-style before copies the preceding row: anchor row 5 and
// insert after it (side=after), so the blank still lands before row 6.
name: "+dim-insert row position=6 count=3 inherit-before",
sc: DimInsert,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "6", "--count", "3", "--inherit-style", "before"},
@@ -56,9 +60,9 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
"excel_id": testToken,
"operation": "insert",
"sheet_id": testSheetID,
"position": "6",
"position": "5",
"count": float64(3),
"side": "before",
"side": "after",
},
},
{
@@ -133,6 +137,47 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
"sheet_id": testSheetID,
},
},
{
// The whole point of --rows/--cols: both axes in ONE operation.
// Two single-axis calls would leave only the last axis frozen,
// because freeze is full-state replacement server-side.
name: "+dim-freeze --rows 1 --cols 2 → one combined op",
sc: DimFreeze,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "1", "--cols", "2"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "freeze",
"sheet_id": testSheetID,
"freeze_rows": float64(1),
"freeze_columns": float64(2),
},
},
{
// Stating the survivor is how you unfreeze one axis and keep the
// other; a zero axis is simply omitted from the body.
name: "+dim-freeze --rows 0 --cols 2 → columns only",
sc: DimFreeze,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "2"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "freeze",
"sheet_id": testSheetID,
"freeze_columns": float64(2),
},
},
{
name: "+dim-freeze --rows 0 --cols 0 → unfreeze",
sc: DimFreeze,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "0", "--cols", "0"},
toolName: "modify_sheet_structure",
wantInput: map[string]interface{}{
"excel_id": testToken,
"operation": "unfreeze",
"sheet_id": testSheetID,
},
},
{
name: "+dim-group row 1:5 fold",
sc: DimGroup,
@@ -169,6 +214,127 @@ func TestSheetStructureShortcuts_DryRun(t *testing.T) {
}
}
func TestDimInsertInheritStyleSideMapping(t *testing.T) {
t.Parallel()
cases := []struct {
name string
position string
inherit string
wantPosition string
wantSide string
wantSideSet bool
}{
{
name: "after copies the following style with a plain before-insert, position unchanged",
position: "D",
inherit: "after",
wantPosition: "D",
wantSide: "before",
wantSideSet: true,
},
{
name: "before anchors one column earlier (side=after) to copy the preceding style",
position: "D",
inherit: "before",
wantPosition: "C",
wantSide: "after",
wantSideSet: true,
},
{
name: "before on a row anchors one row earlier",
position: "6",
inherit: "before",
wantPosition: "5",
wantSide: "after",
wantSideSet: true,
},
{
name: "before at the first column falls back to a plain before-insert",
position: "A",
inherit: "before",
wantPosition: "A",
wantSideSet: false,
},
{
name: "after at the first column still works (before-insert anchors the following)",
position: "A",
inherit: "after",
wantPosition: "A",
wantSide: "before",
wantSideSet: true,
},
{
// The flag documents `after` as its default, so omitting it must
// build the same body rather than leaving `side` to the backend's
// own default — see TestDimInsertOmittedMatchesAfter.
name: "default (flag omitted) sends the same side as --inherit-style after",
position: "D",
wantPosition: "D",
wantSide: "before",
wantSideSet: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
args := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", tc.position, "--count", "1"}
if tc.inherit != "" {
args = append(args, "--inherit-style", tc.inherit)
}
body := parseDryRunBody(t, DimInsert, args)
got := decodeToolInput(t, body, "modify_sheet_structure")
assertInputEquals(t, got, map[string]interface{}{
"excel_id": testToken,
"operation": "insert",
"sheet_id": testSheetID,
"position": tc.wantPosition,
"count": float64(1),
})
gv, ok := got["side"]
if ok != tc.wantSideSet {
t.Fatalf("side presence = %v, want %v (input=%#v)", ok, tc.wantSideSet, got)
}
if ok && gv != tc.wantSide {
t.Fatalf("side = %v, want %q", gv, tc.wantSide)
}
})
}
}
// TestDimInsertOmittedMatchesAfter pins the contract --inherit-style's flag
// description states: omitting it is the same call as passing `after`.
//
// Verified live 07-31 rather than assumed: on a sheet with row2 red and row3
// blue, inserting at --position 3 places the blank at row 3 in all four
// spellings (omitted with no `side` field at all, omitted, `after`, `before`),
// and the blank inherits the FOLLOWING row's blue under omitted/`after` and the
// PRECEDING row's red under `before`. So the backend's own default for `side`
// is "before" and the pre-existing behaviour was already correct; the CLI sends
// the field explicitly only so the documented default stops depending on an
// undocumented server-side one. This test locks the two bodies together, byte
// for byte, so that stays true.
func TestDimInsertOmittedMatchesAfter(t *testing.T) {
t.Parallel()
for _, position := range []string{"1", "3", "A", "D"} {
t.Run("position "+position, func(t *testing.T) {
t.Parallel()
base := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", position, "--count", "1"}
omitted := decodeToolInput(t, parseDryRunBody(t, DimInsert, base), "modify_sheet_structure")
explicit := decodeToolInput(t,
parseDryRunBody(t, DimInsert, append(append([]string{}, base...), "--inherit-style", "after")),
"modify_sheet_structure")
if !reflect.DeepEqual(omitted, explicit) {
t.Fatalf("omitted --inherit-style built %#v, --inherit-style after built %#v; they must be identical", omitted, explicit)
}
})
}
}
// TestDimRange_Validation covers the A1 range parser's edge cases routed
// through +dim-hide (any --range shortcut works; we just need to exercise
// the validator).
@@ -204,6 +370,233 @@ func TestDimRange_Validation(t *testing.T) {
}
}
// TestDimFreezeEquivalent pins the replacement spelling printed by the
// phase-1 deprecation note: it must be the exact --rows/--cols call the user
// should switch to, not a generic pointer. Each pairing is also asserted for
// body equality, which is what makes the legacy form strictly redundant.
func TestDimFreezeEquivalent(t *testing.T) {
t.Parallel()
cases := []struct {
dimension string
count int
want string
}{
{"row", 2, "--rows 2"},
{"column", 3, "--cols 3"},
{"row", 0, "--rows 0 --cols 0"},
{"column", 0, "--rows 0 --cols 0"},
}
for _, tt := range cases {
t.Run(tt.want, func(t *testing.T) {
t.Parallel()
legacy := newMapFlagViewForCommand("+dim-freeze", map[string]interface{}{
"dimension": tt.dimension, "count": tt.count,
})
if got := dimFreezeEquivalent(legacy); got != tt.want {
t.Fatalf("dimFreezeEquivalent = %q, want %q", got, tt.want)
}
// The advertised replacement must produce the identical body.
modern := map[string]interface{}{}
if tt.count > 0 {
if tt.dimension == "row" {
modern["rows"] = tt.count
} else {
modern["cols"] = tt.count
}
} else {
modern["rows"], modern["cols"] = 0, 0
}
legacyInput, err := dimFreezeInput(legacy, testToken, testSheetID, "")
if err != nil {
t.Fatalf("legacy form: %v", err)
}
modernInput, err := dimFreezeInput(newMapFlagViewForCommand("+dim-freeze", modern), testToken, testSheetID, "")
if err != nil {
t.Fatalf("modern form: %v", err)
}
if !reflect.DeepEqual(legacyInput, modernInput) {
t.Fatalf("bodies diverge:\n legacy = %v\n modern = %v", legacyInput, modernInput)
}
})
}
}
// TestRetiredEnumValueMatchesOmitted pins the back-compat contract for enum
// values this CLI retired: --inherit-style none was valid AND the default
// before the side mapping was corrected, so rejecting it would break existing
// scripts and any agent carrying older docs. It must behave exactly as if the
// flag were omitted — on the standalone path and inside +batch-update alike,
// since +dim-insert is batchable and a divergence there would be invisible.
func TestRetiredEnumValueMatchesOmitted(t *testing.T) {
t.Parallel()
base := []string{"--url", testURL, "--sheet-id", testSheetID, "--position", "3", "--count", "1"}
// Must be the registry copy: the retired-value rewrite lives in the
// PostMount ergonomics layer, which Shortcuts() installs and the raw
// exported var does not carry.
dimInsert := shortcutFromRegistry(t, "+dim-insert")
omitted := parseDryRunBody(t, dimInsert, base)
for _, val := range []string{"none", "NONE", "None"} {
got := parseDryRunBody(t, dimInsert, append(append([]string{}, base...), "--inherit-style", val))
if !reflect.DeepEqual(got, omitted) {
t.Fatalf("--inherit-style %s body = %v, want the omitted body %v", val, got, omitted)
}
}
t.Run("still rejects a genuinely invalid value", func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, shortcutFromRegistry(t, "+dim-insert"),
append(append([]string{}, base...), "--inherit-style", "banana", "--dry-run"))
requireValidation(t, err, `invalid value "banana"`)
})
t.Run("reports as absent on both paths, not just empty", func(t *testing.T) {
// The two paths clear the value differently (cobra Set vs deleting the
// raw key), so Changed() is the part that can silently diverge: a flag
// whose logic reads Changed() rather than the value would then behave
// differently standalone than inside +batch-update.
t.Parallel()
parent, _, _, _ := newTestRig(t, shortcutFromRegistry(t, "+dim-insert"))
parent.SetArgs(append([]string{"+dim-insert"},
append(append([]string{}, base...), "--inherit-style", "none", "--dry-run")...))
if err := parent.Execute(); err != nil {
t.Fatalf("dry-run failed: %v", err)
}
cmd, _, err := parent.Find([]string{"+dim-insert"})
if err != nil {
t.Fatalf("find command: %v", err)
}
if cmd.Flags().Changed("inherit-style") {
t.Error("standalone: Changed() must report the retired value as absent")
}
fv := newMapFlagViewForCommand("+dim-insert", map[string]interface{}{
"position": 3, "count": 1, "inherit-style": "none",
})
if err := fv.normalizeAndValidateEnums(); err != nil {
t.Fatalf("batch enum pass: %v", err)
}
if fv.Changed("inherit-style") {
t.Error("batch: Changed() must report the retired value as absent")
}
})
t.Run("batch sub-op treats it the same", func(t *testing.T) {
t.Parallel()
sub := func(extra string) map[string]interface{} {
ops := `[{"shortcut":"+dim-insert","input":{"sheet-id":"sh1","position":3,"count":1` + extra + `}}]`
body := parseDryRunBody(t, shortcutFromRegistry(t, "+batch-update"), []string{"--url", testURL, "--operations", ops})
input, _ := body["input"].(string)
var decoded map[string]interface{}
if err := json.Unmarshal([]byte(input), &decoded); err != nil {
t.Fatalf("decode batch input: %v (raw=%s)", err, input)
}
opsOut, _ := decoded["operations"].([]interface{})
if len(opsOut) != 1 {
t.Fatalf("want 1 translated op, got %v", decoded["operations"])
}
first, _ := opsOut[0].(map[string]interface{})
return first
}
if got, want := sub(`,"inherit-style":"none"`), sub(""); !reflect.DeepEqual(got, want) {
t.Fatalf("batch sub-op with none = %v, want the omitted form %v", got, want)
}
})
}
// TestDimFreezeLegacyNote pins WHERE the phase-1 deprecation steer appears.
// The note used to fire only from the standalone Execute, which missed the two
// paths that matter most: --dry-run (how agents preview before committing to a
// spelling) and +batch-update (where two per-axis sub-ops both report success
// while only the last axis stays frozen).
func TestDimFreezeLegacyNote(t *testing.T) {
t.Parallel()
legacy := []string{"--url", testURL, "--sheet-id", testSheetID, "--dimension", "row", "--count", "2"}
modern := []string{"--url", testURL, "--sheet-id", testSheetID, "--rows", "2"}
t.Run("standalone dry-run carries the note", func(t *testing.T) {
t.Parallel()
warning := dryRunWarning(t, DimFreeze, legacy)
if !strings.Contains(warning, "equivalent to --rows 2") {
t.Fatalf("dry-run warning = %q, want the exact replacement", warning)
}
})
t.Run("modern form stays silent", func(t *testing.T) {
t.Parallel()
if w := dryRunWarning(t, DimFreeze, modern); w != "" {
t.Fatalf("modern form must not warn, got %q", w)
}
})
t.Run("batch sub-op carries the note with its index", func(t *testing.T) {
t.Parallel()
args := []string{"--url", testURL, "--operations",
`[{"shortcut":"+cells-clear","input":{"sheet-id":"sh1","range":"A1:B2"}},` +
`{"shortcut":"+dim-freeze","input":{"sheet-id":"sh1","dimension":"column","count":3}}]`}
warning := dryRunWarning(t, BatchUpdate, args)
if !strings.Contains(warning, "operations[1] (+dim-freeze)") || !strings.Contains(warning, "equivalent to --cols 3") {
t.Fatalf("batch warning = %q, want the indexed note with the replacement", warning)
}
})
t.Run("batch with only modern sub-ops stays silent", func(t *testing.T) {
t.Parallel()
args := []string{"--url", testURL, "--operations",
`[{"shortcut":"+dim-freeze","input":{"sheet-id":"sh1","rows":1,"cols":2}}]`}
if w := dryRunWarning(t, BatchUpdate, args); w != "" {
t.Fatalf("modern sub-op must not warn, got %q", w)
}
})
}
// TestDimFreeze_FormValidation pins the two request forms as mutually
// exclusive, and pins that neither-form is a prescriptive error rather than a
// silent no-op.
func TestDimFreeze_FormValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "forms cannot be mixed",
args: []string{"--rows", "1", "--dimension", "row", "--count", "1"},
want: "not both",
},
{
name: "neither form given",
args: []string{},
want: "nothing to freeze",
},
{
name: "negative rows",
args: []string{"--rows", "-1"},
want: "--rows must be >= 0",
},
{
name: "count without dimension",
args: []string{"--count", "2"},
want: "--dimension is required alongside --count",
},
{
name: "dimension without count",
args: []string{"--dimension", "row"},
want: "--count is required alongside --dimension",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
args := append([]string{"--url", testURL, "--sheet-id", testSheetID, "--dry-run"}, tt.args...)
_, _, err := runShortcutCapturingErr(t, DimFreeze, args)
requireValidation(t, err, tt.want)
})
}
}
// TestDimMove_DryRun verifies the native v3 move_dimension payload shape.
// CLI's --source-range "1:3" (1-based inclusive) is parsed into
// source.{start_index=0, end_index=2} (0-based inclusive), and sheet_id is

View File

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

View File

@@ -0,0 +1,479 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
func stylesPutView(spec map[string]interface{}) mapFlagView {
return newMapFlagViewForCommand("+styles-put", map[string]interface{}{"styles": spec})
}
// TestStylesPutOperations_ExpansionOrder pins the per-sheet expansion:
// cell_merges → cell_styles → row_sizes → col_sizes → freeze, all inside one
// batch_update operations array (server-side order dependence verified live).
func TestStylesPutOperations_ExpansionOrder(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "Sheet1",
"cell_merges": []interface{}{map[string]interface{}{"range": "A5:A8"}},
"cell_styles": []interface{}{map[string]interface{}{"range": "A1:B1", "font_weight": "bold"}},
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36)}},
"col_sizes": []interface{}{map[string]interface{}{"range": "A:B", "type": "pixel", "size": float64(120)}},
"freeze": map[string]interface{}{"rows": float64(1), "cols": float64(2)},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
wantTools := []string{"merge_cells", "set_cell_range", "resize_range", "resize_range", "modify_sheet_structure"}
if len(ops) != len(wantTools) {
t.Fatalf("got %d ops, want %d", len(ops), len(wantTools))
}
for i, want := range wantTools {
op := ops[i].(map[string]interface{})
if op["tool_name"] != want {
t.Fatalf("ops[%d].tool_name = %v, want %s", i, op["tool_name"], want)
}
input := op["input"].(map[string]interface{})
if input["sheet_name"] != "Sheet1" {
t.Fatalf("ops[%d] missing sheet_name: %v", i, input)
}
if input["excel_id"] != testToken {
t.Fatalf("ops[%d] missing excel_id", i)
}
}
// The style stamp carries a cells matrix matching the range (1×2).
stamp := ops[1].(map[string]interface{})["input"].(map[string]interface{})
cells := stamp["cells"].([][]interface{})
if len(cells) != 1 || len(cells[0]) != 2 {
t.Fatalf("style stamp matrix = %dx%d, want 1x2", len(cells), len(cells[0]))
}
// Freeze rows and columns are combined into one operation because freeze is
// full-state replacement server-side (verified 07-31 live): two per-axis
// calls leave only the last axis frozen.
freeze := ops[4].(map[string]interface{})["input"].(map[string]interface{})
if freeze["operation"] != "freeze" || freeze["freeze_rows"] != 1 || freeze["freeze_columns"] != 2 {
t.Fatalf("freeze op = %v", freeze)
}
}
// TestStylesPutOperations_Validation pins the aggregate error shape and the
// section/name requirements.
func TestStylesPutOperations_Validation(t *testing.T) {
t.Parallel()
t.Run("missing name and empty item aggregate", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{
map[string]interface{}{"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}}},
map[string]interface{}{"name": "S2"},
},
}), testToken)
ve := requireValidation(t, err, "name is required")
if !strings.Contains(ve.Message, "at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze") {
t.Fatalf("message %q missing empty-item issue", ve.Message)
}
})
t.Run("duplicate sheet name rejected", func(t *testing.T) {
t.Parallel()
item := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}
item2 := map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(2)}}
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{item, item2},
}), testToken)
requireValidation(t, err, "appears twice")
})
t.Run("freeze-only item is valid", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(1)}}},
}), testToken)
if err != nil || len(ops) != 1 {
t.Fatalf("ops=%d err=%v", len(ops), err)
}
})
t.Run("all-zero freeze rejected", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{"name": "S1", "freeze": map[string]interface{}{"rows": float64(0)}}},
}), testToken)
requireValidation(t, err, "at least one dimension")
})
t.Run("range prefixed with another sheet rejected", func(t *testing.T) {
// Silently stripping "Detail!" would retarget the styles onto Summary.
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "Summary",
"cell_styles": []interface{}{map[string]interface{}{"range": "Detail!A1:D1", "font_weight": "bold"}},
}},
}), testToken)
ve := requireValidation(t, err, `names sheet "Detail" but the item targets "Summary"`)
if !strings.Contains(ve.Message, "cell_styles") {
t.Fatalf("message %q should locate the offending section", ve.Message)
}
})
t.Run("range prefixed with the item's own sheet passes and strips for every visual op", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "Summary",
"cell_styles": []interface{}{map[string]interface{}{"range": "'Summary'!A1:D1", "font_weight": "bold"}},
"cell_merges": []interface{}{map[string]interface{}{"range": "Summary!A2:B2"}, "'Summary'!C2:D2"},
"row_sizes": []interface{}{map[string]interface{}{"range": "Summary!2:3", "type": "pixel", "size": float64(32)}},
"col_sizes": []interface{}{map[string]interface{}{"range": "'Summary'!A:C", "type": "pixel", "size": float64(120)}},
}},
}), testToken)
if err != nil {
t.Fatalf("matching prefix must stay accepted: %v", err)
}
gotRanges := []string{}
for _, raw := range ops {
input := raw.(map[string]interface{})["input"].(map[string]interface{})
if rng, _ := input["range"].(string); rng != "" {
gotRanges = append(gotRanges, rng)
}
}
want := []string{"A2:B2", "C2:D2", "A1:D1", "2:3", "A:C"}
if len(gotRanges) != len(want) {
t.Fatalf("ranges = %v, want %v", gotRanges, want)
}
for i := range want {
if gotRanges[i] != want[i] {
t.Fatalf("ranges = %v, want %v", gotRanges, want)
}
}
})
t.Run("unknown item key rejected with did-you-mean", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"cell_styles": []interface{}{map[string]interface{}{"range": "A1", "font_weight": "bold"}},
"freezee": map[string]interface{}{"rows": float64(1)},
}},
}), testToken)
requireValidation(t, err, `unknown key "freezee" — did you mean "freeze"`)
})
}
// TestStylesPayloadVocabularyForgiveness pins the 07-20 rerun fixes: the
// payload path (--styles cell_styles objects) accepts the same habitual
// vocabulary the flag path already normalized — border family folding, wrap
// aliases, and enum VALUE canonicalization (CSS center → Lark middle etc.).
func TestStylesPayloadVocabularyForgiveness(t *testing.T) {
t.Parallel()
stamp := func(styleFields map[string]interface{}) ([]interface{}, error) {
item := map[string]interface{}{"range": "A1:B1"}
for k, v := range styleFields {
item[k] = v
}
return stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"cell_styles": []interface{}{item},
}},
}), testToken)
}
cellProto := func(t *testing.T, ops []interface{}) map[string]interface{} {
t.Helper()
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
cells := input["cells"].([][]interface{})
return cells[0][0].(map[string]interface{})
}
t.Run("vertical_alignment center canonicalizes to middle", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{"vertical_alignment": "center", "font_weight": "BOLD"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
if cs["vertical_alignment"] != "middle" || cs["font_weight"] != "bold" {
t.Fatalf("cell_styles = %v, want middle/bold", cs)
}
})
t.Run("off-enum value rejected client-side with did-you-mean", func(t *testing.T) {
t.Parallel()
_, err := stamp(map[string]interface{}{"vertical_alignment": "botom"})
requireValidation(t, err, `did you mean "bottom"`)
})
t.Run("boolean wrap_text folds to word_wrap auto-wrap", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{"wrap_text": true})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cs := cellProto(t, ops)["cell_styles"].(map[string]interface{})
if cs["word_wrap"] != "auto-wrap" {
t.Fatalf("word_wrap = %v, want auto-wrap", cs["word_wrap"])
}
})
t.Run("borders object folds into border_styles", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{
"borders": map[string]interface{}{"style": "solid", "color": "#000000"},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
top, _ := bs["top"].(map[string]interface{})
if top == nil || top["style"] != "solid" {
t.Fatalf("border_styles = %v, want all-sides solid", bs)
}
})
t.Run("flattened border_bottom and border_top_color fold per side", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{
"border_bottom": map[string]interface{}{"style": "solid"},
"border_top_color": "#FF0000",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
bottom, _ := bs["bottom"].(map[string]interface{})
topSide, _ := bs["top"].(map[string]interface{})
if bottom["style"] != "solid" || topSide["color"] != "#FF0000" {
t.Fatalf("border_styles = %v", bs)
}
})
t.Run("border_style thin means thin solid line", func(t *testing.T) {
t.Parallel()
ops, err := stamp(map[string]interface{}{"border_style": "thin"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
bs := cellProto(t, ops)["border_styles"].(map[string]interface{})
top, _ := bs["top"].(map[string]interface{})
if top["weight"] != "thin" || top["style"] != "solid" {
t.Fatalf("border_styles.top = %v, want thin solid", top)
}
})
t.Run("fore_color prescribes instead of guessing", func(t *testing.T) {
t.Parallel()
_, err := stamp(map[string]interface{}{"fore_color": "#FF0000"})
requireValidation(t, err, "fore_color is ambiguous")
})
t.Run("bare string cell_merges accepted", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"cell_merges": []interface{}{"A5:B6"},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if input["range"] != "A5:B6" || input["merge_type"] != "all" {
t.Fatalf("merge op = %v", input)
}
})
}
// TestStylesResizeSizeAliases pins the one-way Excel-vocabulary aliases on
// the shared styles resize parser: height in row_sizes / width in col_sizes
// resolve to size silently; the wrong dimension's word is a targeted error.
func TestStylesResizeSizeAliases(t *testing.T) {
t.Parallel()
t.Run("height aliases to size in row_sizes", func(t *testing.T) {
t.Parallel()
ops, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "height": float64(36)}},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := ops[0].(map[string]interface{})["input"].(map[string]interface{})
block := input["resize_height"].(map[string]interface{})
if block["value"] != 36 {
t.Fatalf("resize_height = %v, want value 36", block)
}
})
t.Run("width aliases to size in col_sizes", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"col_sizes": []interface{}{map[string]interface{}{"range": "A:C", "type": "pixel", "width": float64(120)}},
}},
}), testToken)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("wrong-dimension word is a targeted error", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "width": float64(36)}},
}},
}), testToken)
requireValidation(t, err, "does not apply to this array")
})
t.Run("size plus alias together rejected", func(t *testing.T) {
t.Parallel()
_, err := stylesPutOperations(stylesPutView(map[string]interface{}{
"styles": []interface{}{map[string]interface{}{
"name": "S1",
"row_sizes": []interface{}{map[string]interface{}{"range": "1:1", "type": "pixel", "size": float64(36), "height": float64(40)}},
}},
}), testToken)
requireValidation(t, err, "either size or height")
})
}
// TestDimDeleteRangesOps pins the descending-order expansion and the
// same-dimension / non-overlap guards.
func TestDimDeleteRangesOps(t *testing.T) {
t.Parallel()
view := func(ranges ...interface{}) mapFlagView {
return newMapFlagViewForCommand("+dim-delete", map[string]interface{}{"ranges": ranges})
}
t.Run("rows execute descending", func(t *testing.T) {
t.Parallel()
ops, err := dimDeleteRangesOps(view("5:5", "11:13", "8:8"), testToken, "", "S1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got []string
for _, op := range ops {
got = append(got, op.(map[string]interface{})["input"].(map[string]interface{})["range"].(string))
}
want := []string{"11:13", "8:8", "5:5"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("order = %v, want %v", got, want)
}
}
})
t.Run("mixed dimensions rejected", func(t *testing.T) {
t.Parallel()
_, err := dimDeleteRangesOps(view("5:5", "C:C"), testToken, "", "S1")
requireValidation(t, err, "rows OR columns")
})
t.Run("overlap rejected", func(t *testing.T) {
t.Parallel()
_, err := dimDeleteRangesOps(view("5:8", "7:9"), testToken, "", "S1")
requireValidation(t, err, "overlap")
})
t.Run("ranges cannot nest inside batch", func(t *testing.T) {
t.Parallel()
_, err := translateBatchOp(subOp("+dim-delete", map[string]interface{}{
"sheet_name": "S1",
"ranges": []interface{}{"5:5", "8:8"},
}), testToken, 0)
requireValidation(t, err, "not supported inside +batch-update")
})
}
// TestCoalesceStyleStamps_PreservesLastWriteWins pins the ordering contract of
// the stamp optimizer: style writes are field-wise last-write-wins, so two
// same-style stamps may only be merged when nothing between them touches the
// cells whose write would move earlier. Grouping globally by style content
// (the original implementation) turned red → blue → red into red → blue and
// silently changed the final color.
func TestCoalesceStyleStamps_PreservesLastWriteWins(t *testing.T) {
t.Parallel()
red := map[string]interface{}{"background_color": "#FF0000"}
blue := map[string]interface{}{"background_color": "#0000FF"}
stamp := func(rng string, style map[string]interface{}) workbookCreateCellStyleOp {
return workbookCreateCellStyleOp{Range: rng, Style: style}
}
lastStyleFor := func(ops []workbookCreateCellStyleOp, rng string) map[string]interface{} {
var out map[string]interface{}
for _, op := range ops {
if op.Range == rng {
out = op.Style
}
}
return out
}
t.Run("same cell red blue red keeps red last", func(t *testing.T) {
t.Parallel()
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
stamp("A1:A1", red), stamp("A1:A1", blue), stamp("A1:A1", red),
})
if last := lastStyleFor(got, "A1:A1"); last == nil || last["background_color"] != "#FF0000" {
t.Fatalf("final style for A1 = %v, want the trailing red; ops=%+v", last, got)
}
})
t.Run("intervening overlapping stamp blocks the merge", func(t *testing.T) {
t.Parallel()
// bold A1:B1, italic on B1, bold B1 again: merging the two bolds would
// hoist B1's bold ahead of the italic and lose the italic.
bold := map[string]interface{}{"font_weight": "bold"}
italic := map[string]interface{}{"font_style": "italic"}
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
stamp("A1:A1", bold), stamp("A1:A1", italic), stamp("A1:A1", bold),
})
if len(got) != 3 {
t.Fatalf("overlapping intermediate stamp must prevent merging, got %d ops: %+v", len(got), got)
}
})
t.Run("adjacent same-style runs still coalesce", func(t *testing.T) {
t.Parallel()
bold := map[string]interface{}{"font_weight": "bold"}
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
stamp("A1:A1", bold), stamp("A2:A2", bold), stamp("A3:A3", bold),
})
if len(got) != 1 || got[0].Range != "A1:A3" {
t.Fatalf("adjacent same-style stamps should merge into A1:A3, got %+v", got)
}
})
t.Run("disjoint intermediate stamp does not block the merge", func(t *testing.T) {
t.Parallel()
bold := map[string]interface{}{"font_weight": "bold"}
italic := map[string]interface{}{"font_style": "italic"}
got := coalesceStyleStamps([]workbookCreateCellStyleOp{
stamp("A1:A1", bold), stamp("Z9:Z9", italic), stamp("A2:A2", bold),
})
if len(got) != 2 {
t.Fatalf("disjoint intermediate stamp should still allow merging, got %+v", got)
}
if got[0].Range != "A1:A2" {
t.Fatalf("bold stamps should merge to A1:A2, got %+v", got)
}
})
}

View File

@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
return tablePutWrite(ctx, runtime, token, payload, styles)
},
Tips: []string{
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
return nil
}
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
// error, so the retry needs no --print-schema round trip. Field vocabulary
// mirrors tableSheetIn.
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
// validated payload. UseNumber keeps numeric cells as json.Number so large
// integers (order IDs, etc.) survive without precision loss or scientific
@@ -259,7 +265,29 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
Sheets []tableSheetIn `json:"sheets"`
}
if err := dec.Decode(&wire); err != nil {
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
// Eval traces show two distinct decode failures that each burned
// retries: a field with the wrong JSON kind (columns as objects,
// dtypes as an array) — fixed by seeing the expected shape once —
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
var ute *json.UnmarshalTypeError
if errors.As(err, &ute) {
// A mismatch with no field path is the missing envelope: the
// payload IS the sub-sheet list, written without the wrapper.
// Say that in the message — the Go unmarshal text ("cannot
// unmarshal array into Go value of type struct { Sheets …}")
// names the internal type, not the fix.
if ute.Field == "" {
verr = common.ValidationErrorf(
`--sheets: top level must be the object {"sheets":[…]}, got a bare JSON %s; wrap the sub-sheet list in a "sheets" key`,
ute.Value).WithCause(err)
}
return nil, verr.WithHint(
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
tablePutSheetsSkeleton)
}
return nil, verr.WithHint(
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
}
// Reject trailing non-whitespace after the first JSON value: json.Decoder
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
@@ -1178,6 +1206,12 @@ var TableGet = common.Shortcut{
input := map[string]interface{}{
"excel_id": token, "ranges": []string{rng},
"include_styles": true, "value_render_option": "raw_value",
"cell_limit": unboundedReadLimit,
}
// Execute adds these caps too; echoing them here keeps dry-run and the
// real request the same shape, so validating one tells you about the other.
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
sheetSelectorForToolInput(input,
strings.TrimSpace(runtime.Str("sheet-id")),
@@ -1201,15 +1235,37 @@ var TableGet = common.Shortcut{
noHeader := runtime.Bool("no-header")
userRange := strings.TrimSpace(runtime.Str("range"))
sheets := make([]interface{}, 0, len(targets))
for _, t := range targets {
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader)
// The char cap is a memory guard, so it must bound the WHOLE read, not
// each sheet independently: a 30-sheet workbook would otherwise be
// allowed 30× the cap. Track what previous sheets consumed and hand the
// remainder to the next one; when it runs out, stop and name the sheets
// left unread instead of silently returning a short workbook.
budget := maxCharsBudget(runtime)
var unread []string
for i, t := range targets {
remaining := 0
if budget > 0 {
remaining = budget - consumedChars(sheets)
if remaining <= 0 {
for _, rest := range targets[i:] {
unread = append(unread, rest.name)
}
break
}
}
spec, err := readSheetAsSpec(ctx, runtime, token, t, userRange, noHeader, remaining)
if err != nil {
return err
}
sheets = append(sheets, spec)
}
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
return nil
payload := map[string]interface{}{"sheets": sheets}
if len(unread) > 0 {
payload["truncated"] = true
payload["unread_sheets"] = unread
payload["truncation_warning"] = fmt.Sprintf("the %d-char read budget was exhausted before %d sheet(s) were read (%s); re-run per sheet with --sheet-name, or raise --max-chars", budget, len(unread), strings.Join(unread, ", "))
}
return emitReadResult(runtime, payload)
},
Tips: []string{
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
@@ -1326,7 +1382,7 @@ func tableGetSheetMeta(r interface{}) (id, name string, rowCount, colCount int)
// a single `astype()` call covers every column); `formats` is emitted only for
// columns whose source cells carry a non-empty number_format, since `astype`
// ignores it and we'd rather not pollute the output.
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool) (map[string]interface{}, error) {
func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token string, t tableGetSheet, userRange string, noHeader bool, charBudget int) (map[string]interface{}, error) {
emptySpec := func() map[string]interface{} {
return map[string]interface{}{
"name": t.name,
@@ -1354,14 +1410,35 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
"value_render_option": "raw_value",
"cell_limit": unboundedReadLimit,
}
// --max-chars binds the char budget (default 500000); --output-path raises
// it to the bounded offload default. Without this the tool applied its own
// ~50000 default and silently dropped rows past it with no signal in the
// +table-get output. charBudget > 0 caps this sheet by what the whole-
// workbook read has left, so a multi-sheet workbook cannot consume the
// per-sheet cap N times over.
if n, ok := maxCharsInput(runtime); ok {
if charBudget > 0 && charBudget < n {
n = charBudget
}
input["max_chars"] = n
}
sheetSelectorForToolInput(input, t.id, t.name)
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
if err != nil {
return nil, err
}
truncated := cellRangesTruncated(out)
grid := extractCellGrid(out)
if len(grid) == 0 {
return emptySpec(), nil
// An empty grid can itself be the result of clipping (the cap was spent
// before any row came back), so the truncation flag must survive here —
// dropping it reports a partial read as a complete empty sheet.
spec := emptySpec()
if truncated {
spec["truncated"] = true
spec["truncation_warning"] = "the read hit the char cap before any row was returned for this sheet; raise --max-chars or read a narrower --range"
}
return spec, nil
}
var headerRow []map[string]interface{}
@@ -1433,9 +1510,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
if len(formats) > 0 {
spec["formats"] = formats
}
// The tool clipped the read at max_chars: rows past the cap are missing from
// data. Surface it so the caller doesn't mistake a partial read for the whole
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
if truncated {
spec["truncated"] = true
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the sheet to a file under the much larger offload cap. Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
}
return spec, nil
}
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
// max_chars — either the top-level has_more flag or the first range's truncated
// flag. Used by +table-get, whose spec output otherwise drops both signals.
func cellRangesTruncated(out interface{}) bool {
m, ok := out.(map[string]interface{})
if !ok {
return false
}
if hm, ok := m["has_more"].(bool); ok && hm {
return true
}
ranges, _ := m["ranges"].([]interface{})
if len(ranges) > 0 {
if r0, ok := ranges[0].(map[string]interface{}); ok {
if t, ok := r0["truncated"].(bool); ok {
return t
}
}
}
return false
}
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
// or "" for an empty sheet.
//

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"testing"
@@ -1687,3 +1688,66 @@ func TestValidColumnType_AcceptsEmpty(t *testing.T) {
t.Error(`validColumnType("float") = true, want false`)
}
}
// TestTableGet_CharBudgetSpansTheWholeWorkbook pins that --max-chars bounds the
// WHOLE multi-sheet read, not each sheet independently.
//
// The cap is a memory guard on a non-streaming path, so letting every sheet
// spend it in full would let a 30-sheet workbook pull 30x what the caller
// allowed — quietly, since each individual request looks compliant. The clamp
// that prevents it (charBudget in readSheetAsSpec) was unpinned: removing it
// passed the entire suite, because the outer loop's exhaustion check is a
// separate mechanism and keeps working.
//
// Asserted on the wire: sheet 2's request must ask for less than sheet 1's.
func TestTableGet_CharBudgetSpansTheWholeWorkbook(t *testing.T) {
t.Parallel()
const budget = 40000
structure := toolOutputStub(testToken, "read", `{"sheets":[`+
`{"sheet_id":"sh1","sheet_name":"S1","row_count":50,"column_count":3,"index":0},`+
`{"sheet_id":"sh2","sheet_name":"S2","row_count":50,"column_count":3,"index":1}`+
`]}`)
// One reusable stub answers both the current-region probes and the cell
// reads; every captured body is inspected below.
payload := `{"current_region":"A1:B2","ranges":[{"cells":[` +
`[{"value":"col1"},{"value":"col2"}],` +
`[{"value":"a"},{"value":"b"}]` +
`]}]}`
reads := toolOutputStub(testToken, "read", payload)
reads.Reusable = true
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--max-chars", strconv.Itoa(budget)}, structure, reads)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var caps []int
for _, body := range reads.CapturedBodies {
var wire struct {
ToolName string `json:"tool_name"`
Input string `json:"input"`
}
if json.Unmarshal(body, &wire) != nil || wire.ToolName != "get_cell_ranges" {
continue
}
var input struct {
MaxChars int `json:"max_chars"`
}
if json.Unmarshal([]byte(wire.Input), &input) != nil || input.MaxChars == 0 {
continue
}
caps = append(caps, input.MaxChars)
}
if len(caps) < 2 {
t.Fatalf("want a cell read per sheet, captured caps = %v", caps)
}
if caps[0] > budget {
t.Errorf("first sheet asked for max_chars=%d, over the %d budget", caps[0], budget)
}
if caps[1] >= caps[0] {
t.Errorf("second sheet asked for max_chars=%d, not reduced by what the first consumed (%d) — the budget is per-workbook, not per-sheet", caps[1], caps[0])
}
}

View File

@@ -9,10 +9,12 @@ import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/drive"
@@ -405,7 +407,11 @@ var SheetCopy = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+sheet-copy"),
Validate: validateViaInput(sheetCopyInput),
Tips: []string{
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
},
Validate: validateViaInput(sheetCopyInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -914,6 +920,18 @@ type workbookCreateStylePayload struct {
RowSizes []workbookCreateResizeOp
ColSizes []workbookCreateResizeOp
CellMerges []workbookCreateMergeOp
Freeze *workbookCreateFreezeOp
}
// workbookCreateFreezeOp freezes the first Rows rows / Cols columns.
// Zero means "that axis ends up UNFROZEN", not "leave it alone": freeze is
// full-state replacement server-side (see workbookCreateVisualOpInput's freeze
// branch), so a declarative spec that omits an axis is stating it should not be
// frozen. parseWorkbookCreateFreezeOp rejects an all-zero op, so at least one
// axis is always positive here.
type workbookCreateFreezeOp struct {
Rows int
Cols int
}
type workbookCreateCellStyleOp struct {
@@ -965,7 +983,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
if len(items) != 1 {
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
}
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return payload, nil
}
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
@@ -988,21 +1010,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
}
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
var probs []error
for i, item := range items {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
continue
}
if name != payload.Sheets[i].Name {
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
continue
}
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if err != nil {
return nil, err
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if len(itemProbs) > 0 {
probs = append(probs, itemProbs...)
continue
}
out.ByIndex[i] = style
out.ByName[name] = style
}
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return out, nil
}
@@ -1030,182 +1059,468 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
return items, nil
}
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
// are validated even after one fails, and every issue is returned in the
// slice: eval traces show agents fixing --styles errors one round trip per
// error (border side, then row_sizes.type, then size…) because only the
// first was ever reported.
// workbookCreateStyleItemKeys is the full top-level vocabulary of one
// --styles item, shared by the three carriers (+workbook-create /
// +table-put / +styles-put).
var workbookCreateStyleItemKeys = []string{"name", "cell_styles", "row_sizes", "col_sizes", "cell_merges", "freeze"}
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
payload := &workbookCreateStylePayload{}
var err error
if raw, ok := item["cell_styles"]; ok {
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
if err != nil {
return nil, err
var probs []error
// Reject unknown top-level keys first: a typo like "freezee" would
// otherwise be silently dropped while the rest of the item applies.
var unknown []string
for k := range item {
known := false
for _, lk := range workbookCreateStyleItemKeys {
if k == lk {
known = true
break
}
}
if !known {
unknown = append(unknown, k)
}
}
sort.Strings(unknown)
for _, k := range unknown {
msg := fmt.Sprintf("%s has unknown key %q", path, k)
if match := suggest.Closest(strings.ToLower(k), workbookCreateStyleItemKeys, 1); len(match) > 0 {
msg += fmt.Sprintf(" — did you mean %q?", match[0])
}
probs = append(probs, common.ValidationErrorf("%s", msg))
}
// Normalize "Sheet!" range prefixes before the section parsers see them:
// the target sheet is named by the item (or, on +workbook-create --values,
// by the single sheet being created), so a prefix is at best redundant and
// at worst a silent retarget. Stripping is unconditional — an item without
// a name (the --values path, where name is optional) must not be left with
// prefixed ranges the section parsers then reject as malformed. Only the
// "names a DIFFERENT sheet" report needs a name to compare against, so it
// is skipped when there is none.
name, _ := item["name"].(string)
probs = append(probs, normalizeStyleItemRangePrefixes(item, path, strings.TrimSpace(name))...)
if raw, ok := item["cell_styles"]; ok {
var errsHere []error
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
probs = append(probs, errsHere...)
}
if raw, ok := item["row_sizes"]; ok {
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
if err != nil {
return nil, err
}
var errsHere []error
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
probs = append(probs, errsHere...)
}
if raw, ok := item["col_sizes"]; ok {
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
if err != nil {
return nil, err
}
var errsHere []error
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
probs = append(probs, errsHere...)
}
if raw, ok := item["cell_merges"]; ok {
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
var errsHere []error
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
probs = append(probs, errsHere...)
}
if raw, ok := item["freeze"]; ok {
freeze, err := parseWorkbookCreateFreezeOp(raw, path+".freeze")
if err != nil {
return nil, err
probs = append(probs, err)
} else {
payload.Freeze = freeze
}
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
if len(probs) > 0 {
return nil, probs
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 && payload.Freeze == nil {
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges/freeze", path)}
}
return payload, nil
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
// styleItemRangeSections are the --styles item sections whose entries carry an
// A1 range that may be written with a redundant "Sheet!" prefix.
var styleItemRangeSections = []string{"cell_styles", "row_sizes", "col_sizes", "cell_merges"}
// normalizeStyleItemRangePrefixes strips an optional "Sheet!" prefix from every
// range in one --styles item, in place, and reports the ones naming a sheet
// other than the item's own.
//
// Stripping has to happen before the section parsers run: parseWorkbookCreateResizeOp
// feeds the range straight to parseA1Range, so row_sizes like "Sheet1!2:3" fail
// as malformed even though the intent is unambiguous — the target sheet is
// already carried by the item name and by each expanded sub-op's sheet selector.
// A prefix naming a DIFFERENT sheet is an error rather than a strip, because
// stripping alone would silently retarget the operation onto the item's sheet
// (name "Summary" + range "Detail!A1:D1" applying to Summary). It is stripped
// anyway so the section parser reports the entry's own issues instead of piling
// a redundant syntax error on top of the mismatch.
//
// name is "" on +workbook-create --values, whose single styles item needs no
// name (the workbook has exactly one sheet, still unnamed at spec time). There
// is then no sheet to disagree with, so ranges are stripped without the
// mismatch report — stripping still has to happen, or the section parsers see
// a prefixed range and reject it as malformed.
func normalizeStyleItemRangePrefixes(item map[string]interface{}, path, name string) []error {
var probs []error
rewrite := func(section, rangeStr string) (string, bool) {
idx := strings.Index(rangeStr, "!")
if idx < 0 {
return "", false
}
prefix := strings.Trim(strings.TrimSpace(rangeStr[:idx]), "'")
if name != "" && prefix != name {
probs = append(probs, common.ValidationErrorf(
"%s.%s range %q names sheet %q but the item targets %q — drop the prefix, or move the entry into the item for %q",
path, section, rangeStr, prefix, name, prefix))
}
return strings.TrimSpace(rangeStr[idx+1:]), true
}
for _, key := range styleItemRangeSections {
arr, ok := item[key].([]interface{})
if !ok {
continue // a wrong-shaped section is the section parser's to report.
}
for i, elem := range arr {
section := fmt.Sprintf("%s[%d]", key, i)
switch v := elem.(type) {
case map[string]interface{}:
rangeStr, ok := v["range"].(string)
if !ok {
continue // non-string/missing range: the section parser reports it.
}
if stripped, changed := rewrite(section, rangeStr); changed {
v["range"] = stripped
}
case string:
// cell_merges also accepts a bare range string.
if key != "cell_merges" {
continue
}
if stripped, changed := rewrite(section, v); changed {
arr[i] = stripped
}
}
}
}
return probs
}
// parseWorkbookCreateFreezeOp parses a {rows, cols} freeze section. At least
// one dimension must be positive — an all-zero freeze is a no-op the caller
// almost certainly didn't mean.
func parseWorkbookCreateFreezeOp(raw interface{}, path string) (*workbookCreateFreezeOp, error) {
obj, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an object like {\"rows\":1} or {\"rows\":1,\"cols\":2}", path)
}
// "cols" and "columns" are aliases for the same field, so accepting both in
// one object would make the result depend on Go's randomized map iteration
// order — the same payload could freeze 1 column on one run and 2 on the
// next. Reject the conflict instead of silently picking a winner.
if _, hasCols := obj["cols"]; hasCols {
if _, hasColumns := obj["columns"]; hasColumns {
if !jsonEqual(obj["cols"], obj["columns"]) {
return nil, common.ValidationErrorf("%s got conflicting values for \"cols\" and \"columns\" (aliases of the same field) — keep one", path)
}
}
}
out := &workbookCreateFreezeOp{}
// Iterate deterministically so error reporting is stable across runs too.
keys := make([]string, 0, len(obj))
for k := range obj {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := obj[k]
n, isNum := v.(float64)
if !isNum || n != float64(int(n)) || n < 0 {
return nil, common.ValidationErrorf("%s.%s must be a non-negative integer", path, k)
}
switch k {
case "rows":
out.Rows = int(n)
case "cols", "columns":
out.Cols = int(n)
default:
return nil, common.ValidationErrorf("%s.%s is not a supported field (want rows/cols)", path, k)
}
}
if out.Rows == 0 && out.Cols == 0 {
return nil, common.ValidationErrorf("%s must freeze at least one dimension (rows or cols > 0)", path)
}
return out, nil
}
// joinStyleValidationErrors folds the issues collected across one --styles
// parse into a single typed error that lists them all, so the caller can fix
// the whole payload in one retry instead of one error per round trip.
func joinStyleValidationErrors(probs []error) error {
switch len(probs) {
case 0:
return nil
case 1:
// Re-attribute to the outer flag even for a single issue: the inner
// error is scoped to a nested path and carries no Param, so an agent
// would have to parse prose to learn which flag to fix. Message text
// is preserved; only the typed attribution is added — and the inner
// hint rides along, since a lone issue has the outer Hint slot free.
msg, hint := aggregatedIssueParts(probs[0])
verr := sheetsValidationForFlag("styles", "%s", msg).WithCause(probs[0])
if hint != "" {
verr = verr.WithHint("%s", hint)
}
return verr
}
const maxShown = 8
msgs := make([]string, 0, len(probs))
for _, e := range probs {
msgs = append(msgs, aggregatedIssueText(e))
}
suffix := ""
if len(msgs) > maxShown {
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
msgs = msgs[:maxShown]
}
return sheetsValidationForFlag("styles", "--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
WithCause(probs[0])
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if len(style) == 0 {
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
}
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if len(style) == 0 {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
}
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateMergeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
}
mergeType = strings.TrimSpace(v)
}
switch mergeType {
case "all", "rows", "columns":
default:
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
// A bare range string means {range: s, merge_type: all} — the only
// possible reading (07-20 eval hit).
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
raw = map[string]interface{}{"range": strings.TrimSpace(s)}
}
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateMergeOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
}
mergeType = normalizeMergeType(strings.TrimSpace(v))
}
switch mergeType {
case "all", "rows", "columns":
default:
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
return workbookCreateMergeOp{}, err
}
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
}
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
// the caller's enum check to reject.
func normalizeMergeType(v string) string {
lower := strings.ToLower(v)
lower = strings.TrimPrefix(lower, "merge_")
switch lower {
case "all", "rows", "columns":
return lower
}
return v
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateResizeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
ops = append(ops, op)
}
return ops, probs
}
// resizeOpExample renders a complete valid op for the dimension, inlined on
// every type/size error: eval traces show the field errors chaining (type
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
// trip, because no error ever showed a whole valid op at once.
func resizeOpExample(dimension string) string {
if dimension == "column" {
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
}
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
}
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateResizeOp{}, err
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType != "" {
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
}
if resizeType != "pixel" && size > 0 {
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
}
return ops, nil
// size is the canonical dimension key (uniform across row_sizes and
// col_sizes — the array name already carries the dimension). The Excel-
// vocabulary alias (height on rows, width on columns) is accepted
// silently; the WRONG dimension's word is a targeted error, never a
// silent rewrite.
alias, wrongDim := "height", "width"
if dimension == "column" {
alias, wrongDim = "width", "height"
}
if _, has := op[wrongDim]; has {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.%s does not apply to this array (the array name carries the dimension); use size, e.g. %s", path, wrongDim, resizeOpExample(dimension))
}
sizeRaw, hasSize := op["size"]
if aliasRaw, hasAlias := op[alias]; hasAlias {
if hasSize {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s: give either size or %s, not both", path, alias)
}
sizeRaw, hasSize = aliasRaw, true
}
size := 0
if hasSize {
n, ok := util.ToFloat64(sizeRaw)
if !ok || n <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
}
size = int(n)
}
// type is optional ceremony when a pixel size is given: {range, size}
// means a pixel resize, exactly as --width/--height without --type does
// on the flag path. Explicit standard/auto still needs type.
if resizeType == "" {
if size <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s needs size (px) or type (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
}
resizeType = "pixel"
}
if resizeType == "pixel" && size <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
}
if resizeType != "pixel" && size > 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size", alias); err != nil {
return workbookCreateResizeOp{}, err
}
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
}
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
@@ -1245,6 +1560,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if len(in) == 0 {
return nil, nil
}
if err := foldBorderFamilyAliases(in, path); err != nil {
return nil, err
}
if err := normalizeCellStyleAliases(in, path); err != nil {
return nil, err
}
@@ -1259,15 +1577,33 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if !ok {
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
}
expandBorderAllShorthand(m)
if err := validateWorkbookBorderStyles(m, path); err != nil {
return nil, err
}
out["border_styles"] = m
case "value", "formula", "rich_text", "multiple_values", "note", "data_validation":
return nil, common.ValidationErrorf("%s is for styles only; put content in --values or use --sheets for typed cell objects", path)
return nil, common.ValidationErrorf("%s.%s is a content field — a styles spec carries no cell content; write values/formulas via +cells-set or +table-put", path, k)
default:
if !workbookCreateCellStyleField(k) {
return nil, common.ValidationErrorf("%s.%s is not a supported style field", path, k)
// Universal rejection with the full field list: this is the
// mechanism that absorbs the infinite tail of spelling
// permutations at a fixed one-retry cost — silent aliases are
// reserved for high-frequency words from real external
// vocabularies (see the style_vocab.go contract). A curated
// prescription wins over did-you-mean; without one, the
// distance match must be a near-typo (≤2 edits) — a
// concept-swap neighbor (font_bold → font_color, distance 3)
// misleads worse than silence.
msg := fmt.Sprintf("%s.%s is not a supported style field", path, k)
lower := strings.ToLower(k)
if rx, ok := styleFieldPrescriptions[lower]; ok {
msg += " — " + rx
} else if match := suggest.Closest(lower, workbookCreateCellStyleFieldList, 1); len(match) > 0 && suggest.Levenshtein(lower, match[0]) <= 2 {
msg += fmt.Sprintf(" — did you mean %q?", match[0])
}
msg += "; supported: " + strings.Join(workbookCreateCellStyleFieldList, ", ")
return nil, common.ValidationErrorf("%s", msg)
}
cellStyle[k] = v
}
@@ -1278,6 +1614,19 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
return out, nil
}
// workbookCreateCellStyleFieldList is what a caller may WRITE in a cell_styles
// item, in display order for the unknown-field hint — the canonical scalar
// vocabulary (workbookCreateCellStyleField) plus the two border carriers.
// "border" is the documented four-sides shorthand rather than a field the
// switch above ever sees: foldBorderFamilyAliases folds it into border_styles
// first. It belongs in this list because the list answers "what may I write",
// not "what survives normalization".
var workbookCreateCellStyleFieldList = []string{
"font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
"background_color", "horizontal_alignment", "vertical_alignment",
"number_format", "word_wrap", "border", "border_styles",
}
func workbookCreateCellStyleField(name string) bool {
switch name {
case "font_color", "font_family", "font_size", "font_weight", "font_style", "font_line",
@@ -1299,7 +1648,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
switch side {
case "top", "bottom", "left", "right":
default:
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
}
spec, ok := raw.(map[string]interface{})
if !ok {
@@ -1482,7 +1831,7 @@ func appendWorkbookCreateVisualOpsDryRun(dry *common.DryRunAPI, token, sheetID,
}
wireBody, _ := buildToolBody(toolName, input)
dry.POST(toolInvokePath(token, ToolKindWrite)).
Desc(fmt.Sprintf("apply %s %s", op.Kind, op.Range)).
Desc(fmt.Sprintf("apply %s", op.describe())).
Body(wireBody)
}
}
@@ -1502,11 +1851,11 @@ func applyWorkbookCreateVisualOps(ctx context.Context, runtime *common.RuntimeCo
// failing op as a recovery hint when one isn't already set.
if p, ok := errs.ProblemOf(err); ok {
if p.Hint == "" {
p.Hint = fmt.Sprintf("failed while applying %s on %s", op.Kind, op.Range)
p.Hint = fmt.Sprintf("failed while applying %s", op.describe())
}
return err
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s %s failed", op.Kind, op.Range).WithCause(err)
return errs.NewInternalError(errs.SubtypeUnknown, "%s failed", op.describe()).WithCause(err)
}
}
return nil
@@ -1516,7 +1865,7 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
if styles == nil {
return nil
}
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes))
ops := make([]workbookCreateStyleOp, 0, len(styles.CellMerges)+len(styles.RowSizes)+len(styles.ColSizes)+2)
for _, op := range styles.CellMerges {
ops = append(ops, workbookCreateStyleOp{Kind: "cell_merge", Range: op.Range, MergeType: op.MergeType})
}
@@ -1526,6 +1875,9 @@ func workbookCreateVisualOps(styles *workbookCreateStylePayload) []workbookCreat
for _, op := range styles.ColSizes {
ops = append(ops, workbookCreateStyleOp{Kind: "col_size", Range: op.Range, ResizeType: op.ResizeType, Size: op.Size})
}
if styles.Freeze != nil {
ops = append(ops, workbookCreateStyleOp{Kind: "freeze", FreezeRows: styles.Freeze.Rows, FreezeCols: styles.Freeze.Cols})
}
return ops
}
@@ -1535,9 +1887,30 @@ type workbookCreateStyleOp struct {
MergeType string
ResizeType string
Size int
FreezeRows int
FreezeCols int
}
// describe renders the op for dry-run text and failure hints. freeze carries
// counts instead of a range, so "%s %s" of kind and range would trail a blank.
func (op workbookCreateStyleOp) describe() string {
if op.Kind != "freeze" {
return op.Kind + " " + op.Range
}
parts := make([]string, 0, 2)
if op.FreezeRows > 0 {
parts = append(parts, fmt.Sprintf("rows=%d", op.FreezeRows))
}
if op.FreezeCols > 0 {
parts = append(parts, fmt.Sprintf("cols=%d", op.FreezeCols))
}
return "freeze " + strings.Join(parts, " ")
}
func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCreateStyleOp) (map[string]interface{}, string) {
// Every caller names the sheet through the selector, so a "Sheet!" prefix
// left on the range would be a duplicate the backend range parser rejects.
op.Range = stripSheetPrefix(op.Range)
switch op.Kind {
case "cell_merge":
input := map[string]interface{}{
@@ -1564,6 +1937,26 @@ func workbookCreateVisualOpInput(token, sheetID, sheetName string, op workbookCr
input["resize_width"] = block
}
return input, "resize_range"
case "freeze":
// Both axes travel in ONE operation because the backend treats freeze as
// full-state replacement, not a per-axis patch: verified 07-31 on a live
// sheet — freezing 1 row then 2 columns in two calls ends at
// frozen_row_count 0 / frozen_column_count 2, the second call having
// silently dropped the first axis. One call carrying both lands 1/2.
// By the same rule an omitted axis is unfrozen, which is what a
// declarative --styles spec should mean.
input := map[string]interface{}{
"excel_id": token,
"operation": "freeze",
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if op.FreezeRows > 0 {
input["freeze_rows"] = op.FreezeRows
}
if op.FreezeCols > 0 {
input["freeze_columns"] = op.FreezeCols
}
return input, "modify_sheet_structure"
default:
return nil, ""
}

View File

@@ -520,7 +520,8 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
{"values not 2D", []string{"--title", "X", "--values", `["a","b"]`}, "must be an array"},
{"styles not object", []string{"--title", "X", "--styles", `"bold"`}, `shaped as {"styles":[...]}`},
{"styles missing array", []string{"--title", "X", "--styles", `{"value":"x"}`}, "--styles.styles is required"},
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","value":"x"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
{"styles item missing groups", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1"}]}`}, "must include at least one of cell_styles/row_sizes/col_sizes/cell_merges"},
{"styles item unknown key gets did-you-mean", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","freezee":{"rows":1}}]}`}, `unknown key "freezee" — did you mean "freeze"`},
{"cell styles must be array", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":{"range":"A1","font_weight":"bold"}}]}`}, "cell_styles must be an array"},
{"cell style needs range", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"font_weight":"bold"}]}]}`}, "range is required"},
{"nested cell_styles rejected", []string{"--title", "X", "--values", `[["a"]]`, "--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1","cell_styles":{"font_weight":"bold"}}]}]}`}, "put style fields directly"},
@@ -689,3 +690,143 @@ func TestApplyWorkbookCreateStylesToMatrix(t *testing.T) {
}
})
}
// TestStyleItemRangePrefixNormalization pins the "Sheet!" prefix handling at the
// shared item parser, so all three --styles carriers (+workbook-create,
// +table-put, +styles-put) behave the same: a prefix naming the item's own
// sheet is stripped (row_sizes would otherwise fail parseA1Range), one naming a
// different sheet is reported instead of silently retargeting.
func TestStyleItemRangePrefixNormalization(t *testing.T) {
t.Parallel()
t.Run("own-sheet prefix strips across every section", func(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"name": "Summary",
"cell_styles": []interface{}{map[string]interface{}{"range": "Summary!A1:D1", "font_weight": "bold"}},
"cell_merges": []interface{}{map[string]interface{}{"range": "'Summary'!A2:B2"}, "Summary!C2:D2"},
"row_sizes": []interface{}{map[string]interface{}{"range": "Summary!2:3", "type": "pixel", "size": float64(32)}},
"col_sizes": []interface{}{map[string]interface{}{"range": "'Summary'!A:C", "type": "pixel", "size": float64(120)}},
}
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
if len(probs) > 0 {
t.Fatalf("a redundant own-sheet prefix must be accepted: %v", probs)
}
got := []string{
payload.CellStyles[0].Range,
payload.CellMerges[0].Range, payload.CellMerges[1].Range,
payload.RowSizes[0].Range, payload.ColSizes[0].Range,
}
want := []string{"A1:D1", "A2:B2", "C2:D2", "2:3", "A:C"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("ranges = %v, want %v", got, want)
}
}
})
t.Run("foreign-sheet prefix reported alongside the item's other issues", func(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"name": "Summary",
"cell_styles": []interface{}{map[string]interface{}{"range": "Detail!A1:D1", "font_weight": "bold"}},
"row_sizes": []interface{}{map[string]interface{}{"range": "2:3", "type": "custom", "size": float64(32)}},
}
_, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
joined := make([]string, 0, len(probs))
for _, p := range probs {
joined = append(joined, p.Error())
}
all := strings.Join(joined, "\n")
// Both must surface in one pass: stripping the mismatched prefix keeps the
// section parser from burying the real issue under a syntax error.
if !strings.Contains(all, `names sheet "Detail" but the item targets "Summary"`) {
t.Fatalf("probs = %v, want the foreign-prefix issue", all)
}
if !strings.Contains(all, `row_sizes[0].type "custom" is invalid`) {
t.Fatalf("probs = %v, want the type issue reported too", all)
}
})
t.Run("unnamed item still gets its prefixes stripped", func(t *testing.T) {
// +workbook-create --values' styles item needs no name (one sheet, not
// yet named), but stripping must not be conditional on having one: the
// section parsers feed ranges to parseA1Range, so a surviving prefix
// turns an unambiguous spec into a malformed-range error. With no name
// there is simply nothing to disagree with, so no mismatch is reported.
t.Parallel()
item := map[string]interface{}{
"cell_styles": []interface{}{map[string]interface{}{"range": "Sheet1!A1:D1", "font_weight": "bold"}},
"row_sizes": []interface{}{map[string]interface{}{"range": "Sheet1!1:1", "size": float64(30)}},
}
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
if len(probs) > 0 {
t.Fatalf("unexpected probs: %v", probs)
}
if payload.CellStyles[0].Range != "A1:D1" {
t.Fatalf("cell_styles range = %q, want the prefix stripped", payload.CellStyles[0].Range)
}
if payload.RowSizes[0].Range != "1:1" {
t.Fatalf("row_sizes range = %q, want the prefix stripped", payload.RowSizes[0].Range)
}
})
t.Run("all three carriers accept a prefixed row_sizes range", func(t *testing.T) {
// The regression this guards: prefix stripping used to live only on the
// named-item path, so +workbook-create --values (whose item carries no
// name) still failed on "Sheet1!2:3" while +table-put / +styles-put
// accepted it.
t.Parallel()
for _, name := range []string{"", "Sheet1"} {
item := map[string]interface{}{
"row_sizes": []interface{}{map[string]interface{}{"range": "Sheet1!2:3", "size": float64(30)}},
}
if name != "" {
item["name"] = name
}
payload, probs := parseWorkbookCreateStyleItem(item, "--styles.styles[0]")
if len(probs) > 0 {
t.Fatalf("name=%q: unexpected probs: %v", name, probs)
}
if payload.RowSizes[0].Range != "2:3" {
t.Fatalf("name=%q: range = %q, want %q", name, payload.RowSizes[0].Range, "2:3")
}
}
})
}
// TestWorkbookCreateVisualOpInput pins what the shared visual-op builder emits:
// one combined freeze operation, and a range with no sheet prefix (the sheet
// travels in the selector).
func TestWorkbookCreateVisualOpInput(t *testing.T) {
t.Parallel()
t.Run("freeze rows and columns share one operation", func(t *testing.T) {
t.Parallel()
ops := workbookCreateVisualOps(&workbookCreateStylePayload{
Freeze: &workbookCreateFreezeOp{Rows: 1, Cols: 2},
})
if len(ops) != 1 {
t.Fatalf("ops = %d, want 1 combined freeze (a second call resets the first axis — verified live 07-31)", len(ops))
}
input, toolName := workbookCreateVisualOpInput(testToken, "sheet-id", "", ops[0])
if toolName != "modify_sheet_structure" {
t.Fatalf("toolName = %q", toolName)
}
if input["freeze_rows"] != 1 || input["freeze_columns"] != 2 {
t.Fatalf("input = %v, want both axes", input)
}
if got := ops[0].describe(); got != "freeze rows=1 cols=2" {
t.Errorf("describe() = %q", got)
}
})
t.Run("sheet prefix is stripped off the range", func(t *testing.T) {
t.Parallel()
input, toolName := workbookCreateVisualOpInput(testToken, "", "Summary",
workbookCreateStyleOp{Kind: "cell_merge", Range: "Summary!A1:B2", MergeType: "all"})
if toolName != "merge_cells" || input["range"] != "A1:B2" {
t.Fatalf("input = %v (%s), want range A1:B2", input, toolName)
}
})
}

View File

@@ -14,6 +14,7 @@ import (
"path/filepath"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
@@ -38,7 +39,11 @@ import (
// CellsSet wraps set_cell_range: caller provides the cells matrix via --cells
// (JSON), with an optional --copy-to-range to replicate the written block
// across a larger area (formula refs auto-shift).
// across a larger area (formula refs auto-shift). The plural form --writes
// ([{sheet_name, range, cells}, …]) fans scattered regions — cross-sheet
// allowed — into ONE atomic batch_update: eval traces show "fix all broken
// formulas across ranges/sheets" as the dominant homogeneous scenario still
// hand-assembled as +batch-update operations arrays.
var CellsSet = common.Shortcut{
Service: "sheets",
Command: "+cells-set",
@@ -48,9 +53,31 @@ var CellsSet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set"),
Validate: validateViaInput(cellsSetInput),
Tips: []string{
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
`Scattered regions (e.g. fixing formulas across ranges/sheets): --writes '[{"sheet_name":…,"range":…,"cells":[[…]]}, …]' — one batch request (fail-fast, no rollback), sheet selector inside each item.`,
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Changed("writes") {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
_, err = cellsSetWritesOps(runtime, token)
return err
}
return validateViaInput(cellsSetInput)(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
if runtime.Changed("writes") {
ops, _ := cellsSetWritesOps(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
}
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := cellsSetInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "set_cell_range", input)
@@ -60,6 +87,21 @@ var CellsSet = common.Shortcut{
if err != nil {
return err
}
if runtime.Changed("writes") {
ops, err := cellsSetWritesOps(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "batch_update", map[string]interface{}{
"excel_id": token,
"operations": ops,
})
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
@@ -77,6 +119,120 @@ var CellsSet = common.Shortcut{
},
}
// cellsSetWritesOps parses --writes ([{sheet_name|sheet_id, range, cells}, …])
// and expands it into set_cell_range operations for ONE atomic batch_update.
// Single source of truth per item: the sheet selector LIVES IN THE ITEM (same
// convention as +batch-update sub-ops and +styles-put items — no top-level
// fallback, no precedence table to remember). Every item runs through the
// exact standalone pipeline (key vocabulary, style acceptance layer, matrix
// precheck, schema validation) via a per-item flag view, and item errors are
// aggregated so one retry fixes them all.
func cellsSetWritesOps(runtime *common.RuntimeContext, token string) ([]interface{}, error) {
for _, conflicting := range []string{"range", "cells", "copy-to-range"} {
if runtime.Changed(conflicting) {
return nil, sheetsValidationForFlag("writes", "--writes and --%s are mutually exclusive: single region → --range + --cells; multiple regions → --writes alone", conflicting)
}
}
if strings.TrimSpace(runtime.Str("sheet-name")) != "" || strings.TrimSpace(runtime.Str("sheet-id")) != "" {
return nil, sheetsValidationForFlag("writes", "--writes does not accept a top-level sheet selector — put sheet_name (or sheet_id) inside each writes item, same as +batch-update sub-ops")
}
raw, err := requireJSONArray(runtime, "writes")
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, sheetsValidationForFlag("writes", "--writes must be a non-empty JSON array of {sheet_name, range, cells} items")
}
if len(raw) > maxBatchOperations {
return nil, sheetsValidationForFlag("writes", "--writes accepts at most %d items; got %d — merge adjacent regions or split into several calls", maxBatchOperations, len(raw))
}
topLevelOverwrite := runtime.Bool("allow-overwrite")
ops := make([]interface{}, 0, len(raw))
var probs []error
var totalCells int64
for i, v := range raw {
item, ok := v.(map[string]interface{})
if !ok {
probs = append(probs, common.ValidationErrorf("--writes[%d] must be an object like {\"sheet_name\":…,\"range\":…,\"cells\":[[…]]}", i))
continue
}
if err := normalizeSubOpInputKeys("+cells-set", item); err != nil {
probs = append(probs, common.ValidationErrorf("--writes[%d]: %v", i, err))
continue
}
if topLevelOverwrite {
if _, has := item["allow_overwrite"]; !has {
item["allow_overwrite"] = true
}
}
fv := newMapFlagViewForCommand("+cells-set", item)
sheetID := strings.TrimSpace(fv.Str("sheet-id"))
sheetName := strings.TrimSpace(fv.Str("sheet-name"))
input, err := cellsSetInput(fv, token, sheetID, sheetName)
if err != nil {
// Prefix with the item index WITHOUT flattening: cellsSetInput's
// errors carry the domain's prescriptions in Hint (requireSheetSelector's
// "+workbook-info" pointer, for one) and "%v" would render only the
// message, silently costing exactly the guidance this path exists to
// deliver. joinWritesValidationErrors re-reads both fields.
probs = append(probs, prefixValidationIssue(fmt.Sprintf("--writes[%d]", i), err))
continue
}
if cells, ok := input["cells"].([]interface{}); ok {
for _, row := range cells {
if r, ok := row.([]interface{}); ok {
totalCells += int64(len(r))
}
}
}
if err := checkBatchStampBudget("writes", totalCells); err != nil {
return nil, err
}
ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range",
"input": input,
})
}
if err := joinWritesValidationErrors(probs); err != nil {
return nil, err
}
return ops, nil
}
// joinWritesValidationErrors mirrors joinStyleValidationErrors for --writes:
// every item's first error in one message, so the whole payload is fixed in
// a single retry.
func joinWritesValidationErrors(probs []error) error {
switch len(probs) {
case 0:
return nil
case 1:
// Re-attribute to the outer flag even for a single issue: the inner
// error is scoped to a nested path and carries no Param, so an agent
// would have to parse prose to learn which flag to fix. Message text
// is preserved; only the typed attribution is added — and the inner
// hint rides along, since a lone issue has the outer Hint slot free.
msg, hint := aggregatedIssueParts(probs[0])
verr := sheetsValidationForFlag("writes", "%s", msg).WithCause(probs[0])
if hint != "" {
verr = verr.WithHint("%s", hint)
}
return verr
}
const maxShown = 8
msgs := make([]string, 0, len(probs))
for _, e := range probs {
msgs = append(msgs, aggregatedIssueText(e))
}
suffix := ""
if len(msgs) > maxShown {
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
msgs = msgs[:maxShown]
}
return sheetsValidationForFlag("writes", "--writes has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix).
WithCause(probs[0])
}
func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
@@ -91,9 +247,13 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
return nil, err
}
rangeStr := strings.TrimSpace(runtime.Str("range"))
if err := checkCellsMatchRange(cells, rangeStr); err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),
"range": rangeStr,
"cells": cells,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
@@ -124,7 +284,11 @@ var CellsSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-style"),
Validate: validateViaInput(cellsSetStyleInput),
Tips: []string{
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
},
Validate: validateViaInput(cellsSetStyleInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -310,33 +474,92 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
// would otherwise be written silently as the literal text "data.csv". It runs
// in +csv-put's Validate, after resolveInputFlags so an @file / stdin value is
// already its contents (a real CSV blob, never a path) and only a bare value
// reaches here unchanged. It flags the value only when it actually names an
// existing file in the cwd subtree; checking real existence (not name shape)
// means inline content that merely ends in a filename ("see config.json") is
// never misjudged. Fails open: any Stat error or a directory leaves the value
// untouched. Scoped to --csv only — no other flag is affected.
// would otherwise be written silently as the literal text "data.csv" — a wrong
// value in the sheet plus a success exit code, which costs more than a
// rejection because nothing surfaces it. It runs in +csv-put's Validate, after
// resolveInputFlags — so an @file / stdin value is already its contents (a real
// CSV blob, never a path) and only a bare value reaches here unchanged.
//
// Two tiers, because the fix differs:
//
// - the value names an existing file in the cwd subtree → a forgotten "@";
// - the file does not exist but the value is unmistakably path-shaped →
// usually an absolute path (which "@" rejects) that the caller retried
// without the "@", or a stale relative path from another working
// directory. Same silent-write outcome, different prescription: stdin.
//
// Everything else passes through. Existence alone can't carry tier two, so
// shape does — but only the narrow shape defined by csvValueLooksLikePath,
// which is what keeps prose that merely mentions a filename out of it.
// Fails open: any Stat error or a directory falls through to the shape check.
// Scoped to --csv only — no other flag is affected.
//
// A value that arrived via @file / stdin is skipped entirely
// (InputResolvedFromSource): its content was already read from the right
// place and may legitimately look like anything, including a path. That
// also makes stdin the guard-proof way to write such text verbatim.
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
if runtime.InputResolvedFromSource("csv") {
return nil
}
raw := strings.TrimSpace(runtime.Str("csv"))
if raw == "" {
return nil
}
fio := runtime.FileIO()
if fio == nil {
// Hints below use <path> placeholders instead of echoing the raw value
// into command-shaped text: the value is untrusted, and a hint like
// "--csv - < $(id).csv" hands an agent a copy-pasteable command that a
// POSIX shell would expand.
if fio := runtime.FileIO(); fio != nil {
info, err := fio.Stat(raw)
if err == nil && info != nil && !info.IsDir() {
return sheetsValidationForFlag("csv",
"--csv value %q is an existing file, not inline CSV; to read it, pass the same path with an @ prefix (--csv @<path>), or pipe the literal text via stdin (--csv -)",
raw,
)
}
}
if !csvValueLooksLikePath(raw) {
return nil
}
info, err := fio.Stat(raw)
if err != nil || info == nil || info.IsDir() {
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
}
return sheetsValidationForFlag("csv",
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
raw, raw,
"--csv value %q looks like a file path, not inline CSV, and no such file exists under the current directory",
raw,
).WithHint(
"to read a file: --csv @<path> (relative to the current directory; @ rejects absolute paths — pipe such a file in via stdin instead: --csv - < <path>). To write this text into the cell verbatim, pass it on stdin the same way (--csv -); values arriving via stdin or @file skip this check",
)
}
// csvValueLooksLikePath reports whether a --csv value is unmistakably a path
// rather than CSV content. Deliberately narrow: the guard rejects on it, so a
// false positive blocks a legitimate write, and an earlier name-shape
// heuristic was replaced by an existence check precisely because it misjudged
// prose ("改完记得更新config.json"). Three conditions, all required:
//
// no comma / newline / whitespace — real CSV has separators, prose has spaces
// pure ASCII — CJK text is content, never a path here
// a .csv/.tsv extension, or an explicit ./ ../ / ~/ prefix
//
// The extension-or-prefix rule is what keeps ordinary single-cell values safe:
// "N/A" contains a slash but neither, and "README.md" is a filename but not a
// CSV one. A caller who genuinely means such a literal still has stdin.
func csvValueLooksLikePath(s string) bool {
if strings.ContainsAny(s, ", \t\r\n\"") {
return false
}
for _, r := range s {
if r > unicode.MaxASCII {
return false
}
}
lower := strings.ToLower(s)
if strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".tsv") {
return true
}
return strings.HasPrefix(s, "./") || strings.HasPrefix(s, "../") ||
strings.HasPrefix(s, "/") || strings.HasPrefix(s, "~/")
}
func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
@@ -625,6 +848,43 @@ func warnDropdownSourceRangeHighlight(runtime *common.RuntimeContext) {
// and returns its row / column counts. Errors on non-rectangular forms like
// "A:C" (whole-column) or "3:6" (whole-row) — those need a row/col total
// from get_sheet_structure, outside the scope of pure local parsing.
// checkCellsMatchRange rejects, before any network call, the cells-vs-range
// mismatches the server would otherwise fail mid-batch ("cells row count (N)
// does not match range row count (M)" — a recurring server-side error cluster
// in eval traces, and the failure leaves earlier batch sub-ops applied).
// Single-cell ranges are checked too: the server enforces the same strict
// match on a bare "A1" (07-21 rerun, 12 rows against range row count 1) —
// there is no anchor semantics on +cells-set. An unparsable range is the
// range validator's job, not ours.
func checkCellsMatchRange(cells []interface{}, rangeStr string) error {
if len(cells) == 0 {
return sheetsValidationForFlag("cells",
"--cells is empty; to clear values use +cells-clear --scope content (needs --yes), or pass a non-empty 2D array")
}
rows, cols, err := rangeDimensions(rangeStr)
if err != nil {
return nil //nolint:nilerr // an unparsable range is reported by the range validation path with proper context
}
if len(cells) != rows {
return sheetsValidationForFlag("cells",
"--cells has %d rows but --range %q spans %d rows; make them equal (e.g. write N rows to an N-row range)",
len(cells), rangeStr, rows)
}
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
return sheetsValidationForFlag("cells",
"--cells[%d] must be an array (one row of cells) — --cells is always a 2D array, a single cell is [[{…}]]", r)
}
if len(row) != cols {
return sheetsValidationForFlag("cells",
"--cells[%d] has %d columns but --range %q spans %d columns; every row must match the range width",
r, len(row), rangeStr, cols)
}
}
return nil
}
func rangeDimensions(rangeStr string) (rows, cols int, err error) {
if idx := strings.Index(rangeStr, "!"); idx >= 0 {
rangeStr = rangeStr[idx+1:]

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