Compare commits

..

9 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Synced from sheet-skill-spec (SoT).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Changes

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

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
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
211 changed files with 9206 additions and 14531 deletions

View File

@@ -2,33 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.73] - 2026-07-20
### Features
- **apps**: design_html support, creative-design skill, unified TOS publish (#1901)
### Bug Fixes
- **slides**: detect visual elements outside canvas
- reduce public content credential fixture false positives
- standardize CLI shortcut text in English (#1942)
### Documentation
- **base**: reduce filter and update retry loops (#1879)
- **vc**: default transcript routing to smart notes over minutes (#1961)
- clarify local trigger automation (#1958)
### Tests
- synchronize temporary Git maintenance (#1946)
### Misc
- **slides**: update lark-slides skill to 0715 snapshot (#1933)
- [codex] support bot menu events (#1765)
## [v1.0.72] - 2026-07-17
### Features
@@ -1579,7 +1552,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70

View File

@@ -1,107 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"strings"
"github.com/larksuite/cli/internal/event"
)
// BotMenuOutput is the flattened shape for application.bot.menu_v6.
type BotMenuOutput struct {
Type string `json:"type" desc:"Event type; always application.bot.menu_v6"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); prefers header.create_time" kind:"timestamp_ms"`
AppID string `json:"app_id,omitempty" desc:"Application ID from the event header"`
TenantKey string `json:"tenant_key,omitempty" desc:"Tenant key from the event header"`
EventKey string `json:"event_key,omitempty" desc:"Developer-defined bot menu event key"`
MenuTimestamp string `json:"menu_timestamp,omitempty" desc:"Menu click timestamp from the event body" kind:"timestamp_ms"`
OperatorID string `json:"operator_id,omitempty" desc:"Operator open_id; kept as a short alias of operator_open_id" kind:"open_id"`
OperatorOpenID string `json:"operator_open_id,omitempty" desc:"Operator open_id" kind:"open_id"`
OperatorUnionID string `json:"operator_union_id,omitempty" desc:"Operator union_id" kind:"union_id"`
OperatorUserID string `json:"operator_user_id,omitempty" desc:"Operator user_id" kind:"user_id"`
OperatorName string `json:"operator_name,omitempty" desc:"Operator display name"`
}
func processBotMenu(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
AppID string `json:"app_id"`
TenantKey string `json:"tenant_key"`
} `json:"header"`
Event struct {
EventKey string `json:"event_key"`
Timestamp json.RawMessage `json:"timestamp"`
Operator struct {
OperatorID struct {
OpenID string `json:"open_id"`
UnionID string `json:"union_id"`
UserID string `json:"user_id"`
} `json:"operator_id"`
OperatorName string `json:"operator_name"`
} `json:"operator"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
menuTimestamp := timestampMillisString(envelope.Event.Timestamp)
timestamp := envelope.Header.CreateTime
if timestamp == "" {
timestamp = menuTimestamp
}
operatorID := envelope.Event.Operator.OperatorID.OpenID
out := &BotMenuOutput{
Type: eventTypeBotMenuV6,
EventID: envelope.Header.EventID,
Timestamp: timestamp,
AppID: envelope.Header.AppID,
TenantKey: envelope.Header.TenantKey,
EventKey: envelope.Event.EventKey,
MenuTimestamp: menuTimestamp,
OperatorID: operatorID,
OperatorOpenID: operatorID,
OperatorUnionID: envelope.Event.Operator.OperatorID.UnionID,
OperatorUserID: envelope.Event.Operator.OperatorID.UserID,
OperatorName: envelope.Event.Operator.OperatorName,
}
return json.Marshal(out)
}
func rawScalarString(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
return s
}
func timestampMillisString(raw json.RawMessage) string {
s := rawScalarString(raw)
if len(s) == 10 && allDigits(s) {
return s + "000"
}
return s
}
func allDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"encoding/json"
"reflect"
"testing"
"time"
"github.com/larksuite/cli/internal/event"
)
func TestKeysBotMenuMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 1 {
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
}
def := keys[0]
if def.Key != eventTypeBotMenuV6 {
t.Errorf("Key = %q, want %q", def.Key, eventTypeBotMenuV6)
}
if def.EventType != eventTypeBotMenuV6 {
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeBotMenuV6)
}
if def.SubscriptionType != "" {
t.Errorf("SubscriptionType = %q, want default event subscription", def.SubscriptionType)
}
if def.Schema.Custom == nil {
t.Fatal("Schema.Custom is nil")
}
if def.Schema.Custom.Type != reflect.TypeOf(BotMenuOutput{}) {
t.Errorf("custom type = %v, want BotMenuOutput", def.Schema.Custom.Type)
}
if def.Schema.Native != nil {
t.Fatal("Schema.Native must be nil for processed output")
}
if def.Process == nil {
t.Fatal("Process is nil")
}
if !reflect.DeepEqual(def.AuthTypes, []string{"bot"}) {
t.Errorf("AuthTypes = %#v", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeBotMenuV6}) {
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
}
}
func TestBotMenuRegistersCleanly(t *testing.T) {
const key = eventTypeBotMenuV6
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
for _, def := range Keys() {
event.RegisterKey(def)
}
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
func TestProcessBotMenu(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_001",
"event_type": "application.bot.menu_v6",
"create_time": "1776409469273",
"app_id": "cli_test",
"tenant_key": "tenant_test"
},
"event": {
"event_key": "start_eval",
"timestamp": 1776409469000,
"operator": {
"operator_id": {
"open_id": "ou_operator",
"union_id": "on_operator",
"user_id": "user_operator"
},
"operator_name": "Test User"
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
if out.EventID != "ev_menu_001" {
t.Errorf("EventID = %q", out.EventID)
}
if out.Timestamp != "1776409469273" {
t.Errorf("Timestamp = %q", out.Timestamp)
}
if out.EventKey != "start_eval" {
t.Errorf("EventKey = %q", out.EventKey)
}
if out.MenuTimestamp != "1776409469000" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
if out.OperatorID != "ou_operator" || out.OperatorOpenID != "ou_operator" {
t.Errorf("OperatorID/OperatorOpenID = %q/%q", out.OperatorID, out.OperatorOpenID)
}
if out.OperatorUnionID != "on_operator" {
t.Errorf("OperatorUnionID = %q", out.OperatorUnionID)
}
if out.OperatorUserID != "user_operator" {
t.Errorf("OperatorUserID = %q", out.OperatorUserID)
}
if out.OperatorName != "Test User" {
t.Errorf("OperatorName = %q", out.OperatorName)
}
if out.AppID != "cli_test" || out.TenantKey != "tenant_test" {
t.Errorf("AppID/TenantKey = %q/%q", out.AppID, out.TenantKey)
}
}
func TestProcessBotMenuStringTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_002",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": "1776409469001",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1776409469001" {
t.Errorf("Timestamp fallback = %q", out.Timestamp)
}
if out.MenuTimestamp != "1776409469001" {
t.Errorf("MenuTimestamp = %q", out.MenuTimestamp)
}
}
func TestProcessBotMenuSecondsTimestampFallback(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_seconds",
"event_type": "application.bot.menu_v6"
},
"event": {
"event_key": "start_eval",
"timestamp": 1694592375,
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Timestamp != "1694592375000" {
t.Errorf("Timestamp fallback = %q, want seconds normalized to milliseconds", out.Timestamp)
}
if out.MenuTimestamp != "1694592375000" {
t.Errorf("MenuTimestamp = %q, want seconds normalized to milliseconds", out.MenuTimestamp)
}
}
func TestProcessBotMenuTypeUsesLocalConstant(t *testing.T) {
payload := `{
"schema": "2.0",
"header": {
"event_id": "ev_menu_003",
"event_type": "unexpected.event_type",
"create_time": "1776409469275"
},
"event": {
"event_key": "start_eval",
"operator": {
"operator_id": {"open_id": "ou_operator"}
}
}
}`
out := runBotMenu(t, payload)
if out.Type != eventTypeBotMenuV6 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeBotMenuV6)
}
}
func TestProcessBotMenuMalformedPayload(t *testing.T) {
raw := &event.RawEvent{
EventID: "ev_bad",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
}
func runBotMenu(t *testing.T, payload string) BotMenuOutput {
t.Helper()
raw := &event.RawEvent{
EventID: "ev_test",
EventType: eventTypeBotMenuV6,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processBotMenu(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("processBotMenu: %v", err)
}
var out BotMenuOutput
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("unmarshal output: %v\n%s", err, got)
}
return out
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application registers Application-domain EventKeys.
package application
import (
"reflect"
"github.com/larksuite/cli/internal/event"
)
const eventTypeBotMenuV6 = "application.bot.menu_v6"
// Keys returns all Application-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeBotMenuV6,
DisplayName: "Bot menu",
Description: "Triggered when a user clicks a custom bot menu item whose action is configured as a push event.",
EventType: eventTypeBotMenuV6,
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(BotMenuOutput{})},
},
Process: processBotMenu,
AuthTypes: []string{"bot"},
RequiredConsoleEvents: []string{eventTypeBotMenuV6},
},
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,18 +19,12 @@ import (
type eventPayload struct {
Comment *struct {
Body string `json:"body"`
Path string `json:"path"`
} `json:"comment"`
Review *struct {
Body string `json:"body"`
} `json:"review"`
}
type commentContent struct {
Body string
Path string
}
func main() {
eventPath := flag.String("event", os.Getenv("GITHUB_EVENT_PATH"), "GitHub event payload path")
kind := flag.String("kind", os.Getenv("GITHUB_EVENT_NAME"), "GitHub event kind")
@@ -40,11 +34,12 @@ func main() {
fmt.Fprintln(os.Stderr, "comment-audit: --event or GITHUB_EVENT_PATH is required")
os.Exit(2)
}
diags, err := auditEvent(*eventPath, *kind)
body, err := commentBody(*eventPath)
if err != nil {
fmt.Fprintf(os.Stderr, "comment-audit: %v\n", err)
os.Exit(2)
}
diags := diagnostics(publiccontent.ScanComment(*kind, body))
if len(diags) > 0 {
fmt.Fprintln(os.Stderr, auditFailureSummary(len(diags)))
}
@@ -52,44 +47,32 @@ func main() {
os.Exit(report.ExitCode(diags))
}
func auditEvent(eventPath, kind string) ([]report.Diagnostic, error) {
content, err := commentBody(eventPath)
if err != nil {
return nil, err
}
return scanCommentContent(kind, content), nil
}
func scanCommentContent(kind string, content commentContent) []report.Diagnostic {
return diagnostics(publiccontent.ScanCommentAtPath(kind, content.Path, content.Body))
}
func auditFailureSummary(count int) string {
return fmt.Sprintf("post-publication audit found public content findings: %d", count)
}
func commentBody(path string) (commentContent, error) {
func commentBody(path string) (string, error) {
safePath, err := validate.SafeInputPath(path)
if err != nil {
return commentContent{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --event: %v", err).
WithParam("--event").
WithCause(err)
}
data, err := vfs.ReadFile(safePath)
if err != nil {
return commentContent{}, err
return "", err
}
var payload eventPayload
if err := json.Unmarshal(data, &payload); err != nil {
return commentContent{}, err
return "", err
}
switch {
case payload.Comment != nil:
return commentContent{Body: payload.Comment.Body, Path: payload.Comment.Path}, nil
return payload.Comment.Body, nil
case payload.Review != nil:
return commentContent{Body: payload.Review.Body}, nil
return payload.Review.Body, nil
default:
return commentContent{}, nil
return "", nil
}
}

View File

@@ -7,11 +7,9 @@ import (
"errors"
"os"
"path/filepath"
"strconv"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/qualitygate/publiccontent"
)
func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
@@ -34,92 +32,11 @@ func TestCommentBodyReadsSafeRelativeEventPath(t *testing.T) {
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "clean comment" || got.Path != "" {
t.Fatalf("comment content = %#v", got)
if got != "clean comment" {
t.Fatalf("comment body = %q", got)
}
}
func TestCommentBodyReadsReviewCommentPath(t *testing.T) {
dir := t.TempDir()
if err := writeTestFile(filepath.Join(dir, "event.json"), `{"comment":{"body":"test suggestion","path":"cmd/agent/list_test.go"}}`); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
got, err := commentBody("event.json")
if err != nil {
t.Fatalf("commentBody() error = %v", err)
}
if got.Body != "test suggestion" || got.Path != "cmd/agent/list_test.go" {
t.Fatalf("comment content = %#v", got)
}
}
func TestCommentAuditUsesReviewCommentPathForFixtureClassification(t *testing.T) {
dir := t.TempDir()
body := `CLIENT_SECRET=$(security find-generic-password -w)`
event := `{"comment":{"body":` + strconv.Quote(body) + `,"path":"scripts/config_test.sh"}}`
if err := writeTestFile(filepath.Join(dir, "event.json"), event); err != nil {
t.Fatal(err)
}
origDir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(origDir)
})
diags, err := auditEvent("event.json", "pull_request_review_comment")
if err != nil {
t.Fatalf("auditEvent() error = %v", err)
}
for _, diag := range diags {
if diag.Rule == "public_content_generic_credential" {
t.Fatalf("review comment fixture should not be a credential diagnostic: %#v", diags)
}
}
pathless := publiccontent.ScanComment("pull_request_review_comment", body)
for _, finding := range pathless {
if finding.Rule == "public_content_generic_credential" {
return
}
}
t.Fatalf("test precondition failed: pathless comment should be classified as a credential: %#v", pathless)
}
func TestScanCommentContentPreservesReviewCommentPath(t *testing.T) {
providerValue := "gh" + "p_" + "1234567890abcdef" + "1234567890abcdef" + "1234"
content := commentContent{
Body: `cfg := &Config{AccessToken: "` + providerValue + `"}`,
Path: "cmd/agent/list_test.go",
}
diags := scanCommentContent("pull_request_review_comment", content)
for _, diag := range diags {
if diag.Rule != "public_content_generic_credential" {
continue
}
if diag.File != content.Path {
t.Fatalf("credential diagnostic file = %q, want %q", diag.File, content.Path)
}
return
}
t.Fatalf("missing provider credential diagnostic: %#v", diags)
}
func TestCommentBodyRejectsUnsafeEventPath(t *testing.T) {
path := filepath.Join(t.TempDir(), "event.json")
if err := writeTestFile(path, `{"comment":{"body":"clean"}}`); err != nil {

View File

@@ -6,11 +6,10 @@ package diff
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestScopeIncludesChangedSkillAndRelatedDomain(t *testing.T) {
@@ -123,7 +122,8 @@ func writeFile(t *testing.T, repo, rel, content string) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
}
@@ -131,7 +131,8 @@ func runGit(t *testing.T, repo string, args ...string) {
func gitOutput(t *testing.T, repo string, args ...string) string {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.Output()
if err != nil {
t.Fatalf("git %v failed: %v", args, err)

View File

@@ -6,11 +6,10 @@ package publiccontent
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/testutil/gitcmd"
)
func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
@@ -24,10 +23,9 @@ func TestCollectScansOnlyCurrentContributionAndMetadata(t *testing.T) {
runGit(t, repo, "add", "baseline.md")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.md"), `# Public change
api_`+`key = "`+providerValue+`"
api_`+`key = "example-public-key"
`)
runGit(t, repo, "add", "docs/public.md")
runGit(t, repo, "commit", "-m", "add public doc", "-m", "Change"+"-Id: I0123456789abcdef0123456789abcdef01234567")
@@ -201,14 +199,13 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "public.json"), strings.Join([]string{
`{"access_` + `token":"` + providerValue + `"}`,
`{"client_` + `secret": "` + providerValue + `"}`,
`{"tenantAccess` + `Token":"` + providerValue + `"}`,
`{"github` + `Token":"` + providerValue + `"}`,
`{"vendorApi` + `Key":"` + providerValue + `"}`,
`{"slackBot` + `Token":"xoxb_` + `1234567890abcdef"}`,
`{"access_` + `token":"real-json-token"}`,
`{"client_` + `secret": "real ` + `secret value"}`,
`{"tenantAccess` + `Token":"real-tenant-camel-token"}`,
`{"github` + `Token":"real-github-token"}`,
`{"vendorApi` + `Key":"real-vendor-key"}`,
`{"slackBot` + `Token":"xoxb-real-token"}`,
}, "\n")+"\n")
runGit(t, repo, "add", "docs/public.json")
runGit(t, repo, "commit", "-m", "add json config")
@@ -218,7 +215,14 @@ func TestCollectDetectsQuotedJSONCredentialAssignments(t *testing.T) {
for _, item := range got {
if item.File == "docs/public.json" && item.Rule == "public_content_generic_credential" {
count++
for _, forbidden := range []string{providerValue, "xoxb_" + "1234567890abcdef"} {
for _, forbidden := range []string{
"real-json-token",
"real secret value",
"real-tenant-camel-token",
"real-github-token",
"real-vendor-key",
"xoxb-real-token",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("JSON credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -302,8 +306,8 @@ func TestCollectDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
count++
}
}
if count != 2 {
t.Fatalf("angle-wrapped provider credential findings = %d, want 2: %#v", count, got)
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
@@ -334,12 +338,12 @@ func TestCollectDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
count++
}
}
if count != 4 {
t.Fatalf("provider-shaped benign-key findings = %d, want 4: %#v", count, got)
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestCollectDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -354,11 +358,15 @@ func TestCollectAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
@@ -366,7 +374,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
@@ -383,7 +391,7 @@ func TestCollectDetectsAccessKeyCredentials(t *testing.T) {
continue
}
count++
if strings.Contains(item.Excerpt, accessKey) {
if strings.Contains(item.Excerpt, "AKIAIOSFODNN7EXAMPX") {
t.Fatalf("access key finding leaked value in excerpt %q", item.Excerpt)
}
}
@@ -424,7 +432,7 @@ func TestCollectDetectsPrivateKeyAssignments(t *testing.T) {
}
}
func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestCollectDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
repo := newGitRepo(t)
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), "base: true\n")
runGit(t, repo, "add", "docs/config.yaml")
@@ -440,11 +448,15 @@ func TestCollectAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T)
runGit(t, repo, "commit", "-m", "add credential config")
got := collectFromPreviousCommit(t, repo)
var count int
for _, item := range got {
if item.File == "docs/config.yaml" && item.Rule == "public_content_generic_credential" {
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestCollectAllowsBenignUnquotedTokenFields(t *testing.T) {
@@ -477,13 +489,12 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "config.yaml"), strings.Join([]string{
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n")
runGit(t, repo, "add", "docs/config.yaml")
runGit(t, repo, "commit", "-m", "add credential config")
@@ -495,7 +506,13 @@ func TestCollectDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{providerValue} {
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -604,8 +621,7 @@ func TestCollectSkipsOnlyKnownQualityGateFixtureFiles(t *testing.T) {
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan_test.go"), "SECRET_TOKEN=fixture\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "scan.go"), "const privateKeyFixture = \""+privateKeyBeginPrefix+privateKeyMarker+"\"\n")
writeFile(t, filepath.Join(repo, "internal", "qualitygate", "publiccontent", "rules.go"), "markers := []string{\"generated with automation\"}\n")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "tests", "e2e", "new-public-workflow.test.sh"), "SECRET_TOKEN=real-leak\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add scanner fixtures")
@@ -669,11 +685,10 @@ func TestCollectScansAddedLinesInSpecialPathNames(t *testing.T) {
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "base")
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "docs", "has space.md"), "SECRET_TOKEN=space-value\n")
writeFile(t, filepath.Join(repo, `weird"quote.md`), "SECRET_TOKEN=quote-value\n")
runGit(t, repo, "mv", "docs/old.md", "docs/new name.md")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN="+providerValue+"\n")
writeFile(t, filepath.Join(repo, "docs", "new name.md"), "base\nSECRET_TOKEN=rename-value\n")
runGit(t, repo, "add", ".")
runGit(t, repo, "commit", "-m", "add special paths")
@@ -840,7 +855,8 @@ func runGit(t *testing.T, repo string, args ...string) {
if len(args) > 0 && args[0] == "commit" {
args = append([]string{"commit", "--no-verify"}, args[1:]...)
}
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)
@@ -849,7 +865,8 @@ func runGit(t *testing.T, repo string, args ...string) {
func runGitOutput(t *testing.T, repo string, args ...string) []byte {
t.Helper()
cmd := gitcmd.Command(repo, args...)
cmd := exec.Command("git", args...)
cmd.Dir = repo
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v failed: %v\n%s", args, err, out)

View File

@@ -4,15 +4,8 @@
package publiccontent
func ScanComment(kind, body string) []Finding {
return ScanCommentAtPath(kind, "", body)
}
func ScanCommentAtPath(kind, path, body string) []Finding {
if kind == "" {
kind = "comment"
}
if path == "" {
path = kind
}
return scanText(path, "comment", body, isDetectorRuleFile(path))
return scanText(kind, "comment", body, false)
}

View File

@@ -3,10 +3,7 @@
package publiccontent
import (
"strings"
"testing"
)
import "testing"
func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
got := ScanComment("issue_comment", `The published comment included /tmp/harness`+`-agent/run and CCM`+`-Harness: stage-4`)
@@ -20,60 +17,3 @@ func TestScanCommentAuditsPublishedCommentBodies(t *testing.T) {
}
}
}
func TestScanCommentAllowsMermaidCredentialTerminology(t *testing.T) {
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" participant Client",
" participant AccessTokenHashTransport",
" participant SecurityPolicyTransport",
" Client->>AccessTokenHashTransport: Send request with bearer token",
" AccessTokenHashTransport->>AccessTokenHashTransport: Clone request and inject token hash",
" Client -> ClientSecret: Resolve configured credential",
" AccessTokenHashTransport->>SecurityPolicyTransport: Forward enriched request",
"```",
}, "\n")
got := ScanComment("issue_comment", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("mermaid credential terminology should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentDetectsCredentialAssignmentInsideMermaidMessage(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
credentialAssignment := "password=" + providerValue
body := strings.Join([]string{
"```mermaid",
"sequenceDiagram",
" Client->>Server: Send " + credentialAssignment,
"```",
}, "\n")
got := ScanComment("issue_comment", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("credential assignment inside mermaid message should be reported: %#v", got)
}
}
func TestScanCommentAtPathAllowsTestFixtureCredentialPlaceholder(t *testing.T) {
body := `cfg := &core.CliConfig{AppID: "cli_x", AppSecret: "fake-secret"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("review comment test fixture should not be a credential finding: %#v", got)
}
}
}
func TestScanCommentAtPathDetectsProviderCredentialInTestFile(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
body := `cfg := &Config{AccessToken: "` + providerValue + `"}`
got := ScanCommentAtPath("pull_request_review_comment", "cmd/agent/list_test.go", body)
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential in review comment should be reported: %#v", got)
}
}

View File

@@ -1,88 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package publiccontent
import (
"encoding/base64"
"net/url"
"strings"
)
func credentialValueHasStrongEvidence(key, value string) bool {
normalized := strings.TrimRight(strings.TrimSpace(value), ",;")
normalized = strings.TrimSpace(strings.Trim(normalized, `"'<>`))
candidates := credentialEvidenceCandidates(unwrapCredentialValue(normalized))
for _, candidate := range candidates {
if providerCredentialIdentifier(candidate) {
return true
}
}
if isCredentialMetadataField(key) {
return false
}
for _, candidate := range candidates {
if highEntropyCredentialValue(strings.ToLower(candidate)) || base64PaddedCredentialValue(candidate) {
return true
}
}
return percentEncodedCredentialValue(strings.ToLower(candidates[0])) ||
commandSubstitutionLooksCredentialLike(strings.ToLower(normalized))
}
func credentialEvidenceCandidates(value string) []string {
candidates := []string{value}
for range 3 {
decoded, err := url.PathUnescape(value)
if err != nil || decoded == value {
break
}
candidates = append(candidates, decoded)
value = decoded
}
return candidates
}
func isCredentialMetadataField(key string) bool {
if isBenignTokenField(key) {
return true
}
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
if len(parts) < 2 {
return false
}
switch parts[len(parts)-1] {
case "hash", "id", "kind", "marker", "prefix", "transport":
return true
default:
return false
}
}
func base64PaddedCredentialValue(value string) bool {
if len(value) < 16 || !strings.HasSuffix(value, "=") {
return false
}
if _, err := base64.StdEncoding.DecodeString(value); err != nil {
return false
}
return shannonEntropy(strings.TrimRight(value, "=")) >= 3.5
}
func percentEncodedCredentialValue(value string) bool {
if len(value) < 16 {
return false
}
var escapes int
for i := 0; i+2 < len(value); i++ {
if value[i] == '%' && isHexByte(value[i+1]) && isHexByte(value[i+2]) {
escapes++
i += 2
}
}
return escapes >= 2
}
func isHexByte(value byte) bool {
return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f')
}

View File

@@ -13,7 +13,7 @@ import (
)
var (
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*(?::=|[:=])\s*(?:!!str\s+)?(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\x60[^\x60]*\x60)|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\x60\s,}\]]+))`)
credentialAssignmentRE = regexp.MustCompile(`(?i)["']?\b[A-Za-z0-9_-]*(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|password|passwd|token|webhook|access[_-]?token|client[_-]?secret)[A-Za-z0-9_-]*\b["']?\s*[:=]\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|(\$\([^)]*\))|(\$\{\{[^}]+\}\})|([^"'\s,}\]]+))`)
jwtLikeRE = regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`)
credentialURLRE = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^/\s:@]*:[^@\s/]+@[^)\s]+`)
bearerHeaderRE = regexp.MustCompile(`(?i)(?:\bAuthorization\s*:\s*Bearer\s+|["']Authorization["']\s*:\s*["']Bearer\s+)[A-Za-z0-9._+/=-]{12,}`)
@@ -383,63 +383,33 @@ func anglePlaceholderIdentifier(value string) bool {
}
func credentialShapedValue(value string) bool {
normalized := strings.TrimSpace(strings.Trim(strings.TrimSpace(value), `"'<>`))
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
return credentialShapedIdentifier(normalized)
}
func credentialShapedIdentifier(value string) bool {
return providerCredentialIdentifier(value)
}
func providerCredentialIdentifier(value string) bool {
value = strings.TrimSpace(value)
switch {
case providerTokenWithBody(value, "sk_live_", 16, ""),
providerTokenWithBody(value, "sk_test_", 16, ""),
providerTokenWithBody(value, "ghp_", 16, ""),
providerTokenWithBody(value, "gho_", 16, ""),
providerTokenWithBody(value, "ghu_", 16, ""),
providerTokenWithBody(value, "github_pat_", 16, "_"),
providerTokenWithBody(value, "xoxb_", 16, "-"),
providerTokenWithBody(value, "xoxp_", 16, "-"),
providerTokenWithBody(value, "xoxa_", 16, "-"),
providerTokenWithBody(value, "xoxb-", 16, "-"),
providerTokenWithBody(value, "xoxp-", 16, "-"),
providerTokenWithBody(value, "xoxa-", 16, "-"),
awsAccessKeyIdentifier(value):
case strings.HasPrefix(value, "sk_live_"),
strings.HasPrefix(value, "sk_test_"),
strings.HasPrefix(value, "ghp_"),
strings.HasPrefix(value, "gho_"),
strings.HasPrefix(value, "ghu_"),
strings.HasPrefix(value, "github_pat_"),
strings.HasPrefix(value, "xoxb_"),
strings.HasPrefix(value, "xoxp_"),
strings.HasPrefix(value, "xoxa_"):
return true
case strings.HasPrefix(value, "real-") &&
(strings.Contains(value, "secret") ||
strings.Contains(value, "token") ||
strings.Contains(value, "key") ||
strings.Contains(value, "password")):
return true
default:
return false
}
}
func providerTokenWithBody(value, prefix string, minBodyLength int, separators string) bool {
body, ok := strings.CutPrefix(value, prefix)
if !ok || len(body) < minBodyLength {
return false
}
for _, r := range body {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune(separators, r) {
continue
}
return false
}
return true
}
func awsAccessKeyIdentifier(value string) bool {
if len(value) != 20 || (!strings.HasPrefix(value, "AKIA") && !strings.HasPrefix(value, "ASIA")) {
return false
}
for _, r := range value[4:] {
if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
continue
}
return false
}
return true
}
func resourceTokenPlaceholderValue(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
switch normalized {

View File

@@ -47,30 +47,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_private_key_block", file, privateKeyLine, source, "private key block"))
inPrivateKey = false
}
for _, location := range credentialAssignmentRE.FindAllStringIndex(line, -1) {
rawMatch := line[location[0]:location[1]]
if !validCredentialAssignmentStart(line, location[0], rawMatch) {
continue
}
match := credentialAssignmentRE.FindStringSubmatch(rawMatch)
if !isCredentialAssignmentMatch(rawMatch) {
for _, match := range credentialAssignmentRE.FindAllStringSubmatch(line, -1) {
if !isCredentialAssignmentMatch(match[0]) {
continue
}
value := credentialAssignmentValue(match)
keyName, _ := normalizedCredentialAssignmentKey(rawMatch)
evidenceValue := value
if sourceCodeFile(file) {
if rhs, ok := sourceCodeTypedCredentialRHS(line, location[0], rawMatch); ok {
evidenceValue = rhs
}
}
if !(isWebhookCredentialKey(keyName) && webhookAssignmentValueLooksCredentialLike(value)) &&
!credentialValueHasStrongEvidence(keyName, evidenceValue) {
continue
}
keyName, _ := normalizedCredentialAssignmentKey(match[0])
if value == "" ||
isNonSecretLiteralValue(value) ||
isBenignCodeCredentialExpression(file, line, location[0], rawMatch, value) ||
isBenignCodeCredentialExpression(file, line, match[0], value) ||
isPlaceholderValue(value) ||
isPermissionScopeIdentifierAssignment(keyName, value) ||
isResourceTokenPlaceholderAssignment(keyName, value) {
@@ -79,7 +64,7 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
if looksLikeEqualityComparison(value) {
continue
}
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(rawMatch)))
out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0])))
}
for _, match := range jwtLikeRE.FindAllString(line, -1) {
if !isJWTToken(match) {
@@ -138,43 +123,21 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
return out
}
func validCredentialAssignmentStart(line string, start int, match string) bool {
if start <= 0 || credentialAssignmentOperator(match) != ":" {
return true
}
prefix := strings.TrimSpace(line[:start])
for _, arrow := range []string{"-->>", "->>", "-->", "->"} {
if strings.HasSuffix(prefix, arrow) {
return false
}
}
return true
}
func credentialAssignmentOperator(match string) string {
key, ok := credentialAssignmentKey(match)
if !ok {
return ""
}
rest := strings.TrimSpace(match[len(key):])
if strings.HasPrefix(rest, ":=") {
return ":="
}
if strings.HasPrefix(rest, ":") {
return ":"
}
if strings.HasPrefix(rest, "=") {
return "="
}
return ""
}
func isCredentialAssignmentMatch(match string) bool {
name, _, ok := normalizedCredentialAssignment(match)
name, value, ok := normalizedCredentialAssignment(match)
if !ok {
return false
}
return isExplicitCredentialKey(name) || isWebhookCredentialKey(name)
if isWebhookCredentialKey(name) && webhookAssignmentValueLooksCredentialLike(value) {
return true
}
if isBenignTokenField(name) && !credentialShapedValue(value) {
return false
}
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name)
}
func normalizedCredentialAssignmentKey(match string) (string, bool) {
@@ -325,7 +288,7 @@ func tokenLikePlaceholderKey(key string) bool {
func tokenLikePlaceholderValue(key, value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'`))
if normalized == "" || credentialShapedIdentifier(strings.Trim(value, `"'`)) {
if normalized == "" || credentialShapedIdentifier(normalized) {
return false
}
if authCredentialTokenKey(key) {
@@ -360,8 +323,52 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0
}
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, "\"'<>`"))
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
@@ -481,20 +488,17 @@ func numericStringPlaceholderValue(value string) bool {
return true
}
func isBenignCodeCredentialExpression(file, line string, matchStart int, match, value string) bool {
func isBenignCodeCredentialExpression(file, line, match, value string) bool {
normalized := strings.TrimSpace(value)
if strings.HasPrefix(normalized, "regexp.MustCompile(") {
return true
}
if !sourceCodeFile(file) {
if !sourceCodeFile(file) || credentialShapedValue(value) {
return false
}
if rhs, ok := sourceCodeTypedCredentialRHS(line, matchStart, match); ok {
if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok {
return isBenignTypedCredentialRHS(rhs)
}
if credentialShapedValue(value) {
return false
}
rawValueQuoted := credentialAssignmentRawValueQuoted(match)
if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) {
return true
@@ -514,16 +518,17 @@ func isBenignCodeCredentialExpression(file, line string, matchStart int, match,
return codeReferenceExpression(normalized)
}
func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (string, bool) {
if matchStart < 0 || matchStart+len(match) > len(line) || line[matchStart:matchStart+len(match)] != match {
func sourceCodeTypedCredentialRHS(line, match string) (string, bool) {
idx := strings.Index(line, match)
if idx < 0 {
return "", false
}
key, ok := credentialAssignmentKey(match)
if !ok {
return "", false
}
rest := strings.TrimSpace(line[matchStart+len(key):])
if !strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, ":=") {
rest := strings.TrimSpace(line[idx+len(key):])
if !strings.HasPrefix(rest, ":") {
return "", false
}
typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":"))
@@ -531,12 +536,7 @@ func sourceCodeTypedCredentialRHS(line string, matchStart int, match string) (st
if assignmentIdx < 0 {
return "", false
}
rhs := strings.TrimSpace(typeAndRHS[assignmentIdx+1:])
parsed := credentialAssignmentRE.FindStringSubmatch("client_secret=" + rhs)
if parsed == nil {
return rhs, true
}
return credentialAssignmentValue(parsed), true
return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true
}
func isBenignTypedCredentialRHS(value string) bool {
@@ -568,7 +568,7 @@ func credentialAssignmentRawValueQuoted(match string) bool {
func sourceCodeFile(file string) bool {
switch filepath.Ext(file) {
case ".go", ".js", ".jsx", ".py", ".sh", ".ts", ".tsx":
case ".go", ".js", ".jsx", ".py", ".ts", ".tsx":
return true
default:
return false
@@ -593,7 +593,6 @@ func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool {
sourceCodeFakeOrPlaceholderLiteral(literal) ||
sourceCodeCredentialTermLiteral(literal) ||
sourceCodeCredentialPrefixLiteral(literal) ||
sourceCodeStringExpressionLiteral(literal) ||
sourceCodeVocabularyLiteral(literal) ||
sourceCodeSchemaTypeLiteral(literal) ||
benignCredentialStatusLiteral(literal)
@@ -686,18 +685,6 @@ func sourceCodeCredentialPrefixLiteral(value string) bool {
}
}
func sourceCodeStringExpressionLiteral(value string) bool {
normalized := strings.TrimSpace(value)
if normalized == "" ||
credentialShapedIdentifier(normalized) ||
highEntropyCredentialValue(strings.ToLower(normalized)) {
return false
}
return strings.Contains(normalized, "${") ||
strings.Contains(normalized, "$(") ||
(strings.Contains(normalized, `\b`) && strings.ContainsAny(normalized, "|[]{}()+*?"))
}
func sourceCodeVocabularyLiteral(value string) bool {
switch strings.ToLower(value) {
case "bot", "tenant", "user":
@@ -766,7 +753,7 @@ func codeIdentifier(value string) bool {
func isNonSecretLiteralValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) {
case "true", "false", "null", "nil", "{", "[", `\`:
case "true", "false", "null", "nil", "{", "[":
return true
default:
return false
@@ -993,7 +980,6 @@ func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"p%40ss",
"pass",
"password",
"pat_abc",

View File

@@ -251,22 +251,26 @@ func TestScanFileDoesNotTreatURLEncodedCredentialAsPlaceholder(t *testing.T) {
}
}
func TestScanFileAllowsReadablePlaceholderMarkerSubstrings(t *testing.T) {
func TestScanFileDoesNotTreatPlaceholderMarkerSubstringsAsPlaceholders(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"API_KEY=notredactedreal",
"API_KEY=notplaceholdersecret",
"API_KEY=abcxxxxreal",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable credential words should not be findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("placeholder-marker substring findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
paddedSecretPrefix := "dGhpc2lz" + "YXNlY3JldA"
paddedTokenPrefix := "UTdrMm1O" + "OXBSNHZYOA"
paddedTokenPrefix := "YWJj" + "ZGVmZ2g"
paddedSecret := base64PaddedFixture(paddedSecretPrefix)
paddedToken := base64PaddedFixture(paddedTokenPrefix)
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
@@ -290,25 +294,17 @@ func TestScanFileDetectsBase64PaddedCredentialAssignments(t *testing.T) {
}
}
func TestScanFileAllowsReadableBase64Lookalike(t *testing.T) {
got := ScanFile("docs/config.md", []byte("client_secret=placeholder=\n"))
if findingRules(got)["public_content_generic_credential"] {
t.Fatalf("readable base64 lookalike should not be a credential finding: %#v", got)
}
}
func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
jsonToken := providerValue
jsonSecret := providerValue
jsonKey := providerValue
jsonTenantToken := providerValue
jsonAppSecret := providerValue
jsonPrefixedKey := providerValue
jsonTenantCamelToken := providerValue
jsonGithubToken := providerValue
jsonVendorKey := providerValue
jsonSlackBotToken := "xoxb_" + "1234567890abcdef"
jsonToken := "real-json-token"
jsonSecret := "real " + "secret value"
jsonKey := "real-json-key"
jsonTenantToken := "real-tenant-json-token"
jsonAppSecret := "real-app-secret"
jsonPrefixedKey := "real-prefixed-key"
jsonTenantCamelToken := "real-tenant-camel-token"
jsonGithubToken := "real-github-token"
jsonVendorKey := "real-vendor-key"
jsonSlackBotToken := "xoxb-real-token"
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_` + `token":"` + jsonToken + `"}`,
`{"client_` + `secret": "` + jsonSecret + `"}`,
@@ -338,13 +334,12 @@ func TestScanFileDetectsQuotedJSONCredentialAssignments(t *testing.T) {
}
func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: " + providerValue,
"TOKEN_GITHUB: " + providerValue,
"CLIENT_SECRET_GOOGLE: " + providerValue,
"SECRET_KEY_BASE: " + providerValue,
"APP_PASSWORD_PROD: " + providerValue,
"API_KEY_OPENAI: real-openai-key",
"TOKEN_GITHUB: real-github-token",
"CLIENT_SECRET_GOOGLE: real-google-secret",
"SECRET_KEY_BASE: real-secret-key-base",
"APP_PASSWORD_PROD: real-prod-password",
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -352,7 +347,13 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
continue
}
count++
for _, forbidden := range []string{providerValue} {
for _, forbidden := range []string{
"real-openai-key",
"real-github-token",
"real-google-secret",
"real-secret-key-base",
"real-prod-password",
} {
if strings.Contains(item.Excerpt, forbidden) {
t.Fatalf("credential finding leaked value %q in excerpt %q", forbidden, item.Excerpt)
}
@@ -363,77 +364,85 @@ func TestScanFileDetectsCredentialPhraseBeforeEnvironmentSuffix(t *testing.T) {
}
}
func TestScanFileAllowsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
func TestScanFileDetectsCredentialValuesThatLookLikeBareIdentifiers(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_OPENAI: prod_key",
"CLIENT_SECRET_GOOGLE: prod_secret",
"TOKEN_GITHUB: github_token",
"APP_PASSWORD_PROD: prod_password",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable identifiers should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("bare identifier credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsAngleWrappedRealisticCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "API_KEY: <" + stripeLike + ">", want: true},
{name: "github", text: "SECRET_TOKEN: <" + patLike + ">", want: true},
{name: "readable", text: "CLIENT_SECRET: <real-client-secret-value>", want: false},
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY: <" + stripeLike + ">",
"SECRET_TOKEN: <" + patLike + ">",
"CLIENT_SECRET: <real-client-secret-value>",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
if count != 3 {
t.Fatalf("angle-wrapped realistic credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedValuesUnderBenignKeys(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "expiry provider token", text: `{"access_token_expires_in":"` + patLike + `"}`, want: true},
{name: "expiry provider secret", text: `{"refresh_token_expires_in":"` + stripeLike + `"}`, want: true},
{name: "status readable", text: `{"client_secret_status":"real-client-secret-value"}`, want: false},
{name: "name readable", text: `{"client_secret_name":"real-client-secret-value"}`, want: false},
{name: "app provider token", text: `{"app_token":"` + patLike + `"}`, want: true},
{name: "sync provider secret", text: `{"sync_token":"` + stripeLike + `"}`, want: true},
{name: "target readable", text: `{"target_token":"real-client-secret-value"}`, want: false},
got := ScanFile("docs/public.json", []byte(strings.Join([]string{
`{"access_token_expires_in":"` + patLike + `"}`,
`{"refresh_token_expires_in":"` + stripeLike + `"}`,
`{"client_secret_status":"real-client-secret-value"}`,
`{"client_secret_name":"real-client-secret-value"}`,
`{"app_token":"` + patLike + `"}`,
`{"sync_token":"` + stripeLike + `"}`,
`{"target_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/public.json", tc.text, tc.want)
})
if count != 7 {
t.Fatalf("credential-shaped benign-key findings = %d, want 7: %#v", count, got)
}
}
func TestScanFileAllowsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
func TestScanFileDetectsBareIdentifierCredentialsWithMetadataSuffixes(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY_NAME: prod_key",
"CLIENT_SECRET_NAME: prod_secret",
"SECRET_STATUS: prod_secret",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("readable metadata values should not be credential findings: %#v", got)
count++
}
}
if count != 3 {
t.Fatalf("metadata-suffixed bare credential findings = %d, want 3: %#v", count, got)
}
}
func TestScanFileDetectsAccessKeyCredentials(t *testing.T) {
accessKey := "AK" + "IAIOSFODNN7EXAMPXX"
accessKey := "AK" + "IAIOSFODNN7EXAMPX"
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"AWS_ACCESS_KEY_ID: " + accessKey,
"ACCESS_KEY_ID: " + accessKey,
@@ -584,18 +593,18 @@ func TestScanFileAllowsCredentialReferenceValues(t *testing.T) {
func TestScanFileDetectsMalformedGithubExpressionCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
cases := []struct {
name string
text string
want bool
}{
{name: "provider", text: "API_KEY=${{" + stripeLike + "}}", want: true},
{name: "readable", text: "TOKEN=${{real-secret-token-value}}", want: false},
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"API_KEY=${{" + stripeLike + "}}",
"TOKEN=${{real-secret-token-value}}",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.yaml", tc.text, tc.want)
})
if count != 2 {
t.Fatalf("malformed GitHub expression credential findings = %d, want 2: %#v", count, got)
}
}
@@ -639,7 +648,6 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`proxy := "http://user:p%40ss@proxy:8080/path"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
@@ -813,151 +821,35 @@ func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *tes
}
}
func TestScanFileAllowsStrongAuthTokenKeysWithoutStrongValueEvidence(t *testing.T) {
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("token field names alone should not produce findings: %#v", got)
}
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(strings.Join([]string{
`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`,
`cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_abc\nFEISHU_APP_SECRET=secret\n"), 0600)`,
`rt := &stubRoundTripper{respBody: ` + "`" + `{"access_token":"t","token_type":"Bearer"}` + "`" + `}`,
`envContent := "FEISHU_APP_ID=cli_hermes_abc\nFEISHU_APP_SECRET=hermes_secret_123\nFEISHU_DOMAIN=lark\n"`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_auto\nFEISHU_APP_SECRET=auto_secret\n"), 0600)`,
`os.WriteFile(path, []byte("FEISHU_APP_ID=cli_new_app\nFEISHU_APP_SECRET=new_secret\n"), 0600)`,
`if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" {`,
`if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" {`,
`if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" {`,
`return &core.CliConfig{AppID: "dummy", AppSecret: "dummy"}`,
`os.WriteFile(path, []byte("API_KEY=replace-me\n"), 0600)`,
`body := "APP_ID=\"cli_xxxxx\"\nAPP_SECRET=\"xxxxx\"\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsCredentialIdentifierFields(t *testing.T) {
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "k1",`,
`"secret_id": "s1",`,
`"token_id": "t1",`,
`"private_key_id": "pk1",`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("credential identifier fields should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialShapedIdentifierFieldValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/openapi_key_test.go", []byte(strings.Join([]string{
`"api_key_id": "` + stripeLike + `",`,
`"token_id": "` + githubToken + `",`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped identifier field findings = %d, want 2: %#v", count, got)
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestCredentialShapedValueTrimsWhitespaceBeforeDelimiters(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
if !credentialShapedValue(` "` + providerValue + `" `) {
t.Fatal("space-padded quoted provider credential should be recognized")
}
}
func TestScanFileDetectsProviderCredentialsAcrossAssignmentSyntaxes(t *testing.T) {
providerValue := strings.Join([]string{"gh", "p_", "1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []struct {
name string
path string
text string
}{
{name: "Go raw string", path: "pkg/config.go", text: "const clientSecret = `" + providerValue + "`"},
{name: "TypeScript template literal", path: "pkg/config.ts", text: "const clientSecret = `" + providerValue + "`;"},
{name: "shell backtick", path: "scripts/config.sh", text: "client_secret=`" + providerValue + "`"},
{name: "YAML string tag", path: "docs/config.yaml", text: "client_secret: !!str " + providerValue},
{name: "YAML string tag double quoted", path: "docs/config.yaml", text: `client_secret: !!str "` + providerValue + `"`},
{name: "YAML string tag single quoted", path: "docs/config.yaml", text: `client_secret: !!str '` + providerValue + `'`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile(tt.path, []byte(tt.text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("provider credential should be reported: %#v", got)
}
})
}
}
func TestScanFileDetectsPercentEncodedProviderCredential(t *testing.T) {
providerBody := strings.Join([]string{"1234567890abcdef", "1234567890abcdef", "1234"}, "")
tests := []string{
"access_token: ghp%" + "5F" + providerBody,
"access_token_hash: ghp%" + "255F" + providerBody,
}
for _, text := range tests {
got := ScanFile("docs/config.yaml", []byte(text+"\n"))
if !findingRules(got)["public_content_generic_credential"] {
t.Fatalf("percent-encoded provider credential should be reported: %#v", got)
}
}
}
func TestScanFileRequiresCompleteProviderCredentialFormats(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte(strings.Join([]string{
"token_type: asian",
"token_prefix: ASIA",
"token_prefix: ghp_",
"api_key: sk_live_example",
"token_prefix: asianmarketsegment01",
"token_prefix: ghp_placeholder_value",
}, "\n")+"\n"))
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("incomplete provider prefixes should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsEncodedTokenMetadataURL(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("token_url: https%3A%2F%2Fexample.invalid/oauth/token\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("encoded token metadata URL should not be credential finding: %#v", got)
t.Fatalf("test fixture secret should not be credential finding: %#v", got)
}
}
}
func TestScanFileAllowsRegexpTokenValidators(t *testing.T) {
got := ScanFile("fixtures/minutes_detail.go", []byte(strings.Join([]string{
"var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)",
"REALISTIC_TOKEN_RE=\"\\\"${TOKEN_BODY}\\\"|\\`${TOKEN_BODY}\\`|\\\\b${TOKEN_BODY}\\\\b\"",
}, "\n")+"\n"))
got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("regexp token validator should not be credential finding: %#v", got)
@@ -1035,22 +927,6 @@ func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) {
}
}
func TestScanFileAllowsSourceCodeSyntheticCredentialIdentifiers(t *testing.T) {
got := ScanFile("fixtures/sheets_media.go", []byte(strings.Join([]string{
`const fakeOfficeTokenPrefix = "fake_office_"`,
`const localOfficeTokenPrefix = "local_office_"`,
`const imageLiveSecretMarker = "img_live_secret"`,
`const imageProdKeyMarker = "img_prod_key"`,
`if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {`,
`if strings.HasPrefix(spreadsheetToken, localOfficeTokenPrefix) {`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("source code token prefix references should not be credential findings: %#v", got)
}
}
}
func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`app_secret=***`,
@@ -1065,18 +941,22 @@ func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsPartiallyMaskedCredentialValues(t *testing.T) {
func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) {
got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{
"client_secret=realprefix***realsuffix",
"client_secret=ab********cd",
"access_token=ab********cd",
"refresh_token=realprefix********realsuffix",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("partially masked values should not be credential findings: %#v", got)
count++
}
}
if count != 4 {
t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
@@ -1092,7 +972,6 @@ func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) {
}
func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
file string
@@ -1101,47 +980,32 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
{
name: "typescript simple secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"`,
text: `const clientSecret: string = "real-client-secret-value"`,
},
{
name: "typescript terminated secret",
name: "typescript numeric password",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `";`,
},
{
name: "typescript secret with trailing comment",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `"; // production`,
},
{
name: "typescript asserted secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string = "` + providerValue + `" as const;`,
},
{
name: "typescript provider password",
file: "fixtures/source_secret.ts",
text: `const password: string = "` + providerValue + `"`,
text: `const password: string = "12345678901234567890"`,
},
{
name: "typescript union secret",
file: "fixtures/source_secret.ts",
text: `const clientSecret: string | undefined = "` + providerValue + `"`,
text: `const clientSecret: string | undefined = "real-client-secret-value"`,
},
{
name: "python simple secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str = "` + providerValue + `"`,
text: `self.client_secret: str = "real-client-secret-value"`,
},
{
name: "python union secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: str | None = "` + providerValue + `"`,
text: `self.client_secret: str | None = "real-client-secret-value"`,
},
{
name: "python optional secret",
file: "fixtures/source_secret.py",
text: `self.client_secret: Optional[str] = "` + providerValue + `"`,
text: `self.client_secret: Optional[str] = "real-client-secret-value"`,
},
}
for _, tc := range cases {
@@ -1154,154 +1018,24 @@ func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) {
}
}
func TestScanFileDetectsRepeatedTypedCredentialAssignments(t *testing.T) {
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "placeholder";`, false)
assertGenericCredentialFinding(t, "fixtures/source_secret.ts", `const clientSecret: string = "`+providerValue+`";`, true)
got := ScanFile("fixtures/source_secret.ts", []byte(
`const clientSecret: string = "placeholder"; const clientSecret: string = "`+providerValue+`";`+"\n",
))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 1 {
t.Fatalf("repeated typed credential findings = %d, want 1: %#v", count, got)
}
}
func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: `const ClientSecret = "` + stripeLike + `"`, want: true},
{name: "github", text: `const GithubToken = "` + githubToken + `"`, want: true},
{name: "password number", text: `const Password = "12345678901234567890"`, want: false},
{name: "secret number", text: `const ClientSecretNumber = "12345678901234567890"`, want: false},
{name: "format literal", text: `const ClientSecretFormat = "abc%sdefreal"`, want: false},
{name: "inline format literal", text: `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "fixtures/source_secret.go", tc.text, tc.want)
})
}
}
func TestScanFileDetectsGoShortDeclarationCredentials(t *testing.T) {
providerSecret := "sk_" + "live_1234567890abcdef"
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{
`clientSecret := "` + providerSecret + `"`,
`accessToken := "` + providerToken + `"`,
`const ClientSecret = "real-client-secret-value"`,
`const GithubToken = "` + githubToken + `"`,
`const Password = "12345678901234567890"`,
`const ClientSecretNumber = "12345678901234567890"`,
`const ClientSecretFormat = "abc%sdefreal"`,
`fmt.Println("done"); const ClientSecret = "abc%sdefreal"`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 2 {
t.Fatalf("Go short declaration credential findings = %d, want 2: %#v", count, got)
}
}
func TestGenericCredentialDecisionMatrix(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tokenHash := "6f1ed002ab559585" + "9014ebf0951522d9" +
"a0e3c1f4206254d" + "28a13efbbc8d56a30"
tests := []struct {
name string
path string
text string
comment bool
want bool
}{
{name: "source synthetic token prefix", path: "pkg/sheets.go", text: `const localOfficeTokenPrefix = "local_office_"`, want: false},
{name: "source token kind state", path: "pkg/client.py", text: `self._token_kind: TokenKind | None = None`, want: false},
{name: "documentation token prefix", path: "docs/config.yaml", text: `token_prefix: local_office_`, want: false},
{name: "documentation token kind", path: "docs/config.yaml", text: `token_kind: bearer`, want: false},
{name: "documentation token hash", path: "docs/config.yaml", text: `access_token_hash: ` + tokenHash, want: false},
{name: "comment fixture placeholder", text: `AppSecret: "fake-secret"`, comment: true, want: false},
{name: "test fixture placeholder", path: "pkg/config_test.go", text: `AppSecret: "fake-secret"`, want: false},
{name: "test real-labeled token", path: "pkg/config_test.go", text: `token: "real-tenant-access-token"`, want: false},
{name: "test ambiguous concrete secret word", path: "pkg/config_test.go", text: `AppSecret: "supersecret"`, want: false},
{name: "resource token placeholder", path: "docs/images.md", text: `"token": "img_abc123"`, want: false},
{name: "partially masked token", path: "docs/auth.md", text: `token=ab********cd`, want: false},
{name: "source readable secret words", path: "pkg/config.go", text: `const AppSecret = "customer-prod-secret"`, want: false},
{name: "documentation readable secret words", path: "docs/config.yaml", text: `client_secret: customer-prod-secret`, want: false},
{name: "comment middle fixture marker", text: `API_KEY=prod-fake-key`, comment: true, want: false},
{name: "comment negated fixture marker", text: `AppSecret: "not-fake-secret"`, comment: true, want: false},
{name: "source with credential words", path: "pkg/config.go", text: `secretWithPassword := "hunter2"`, want: false},
{name: "production filename containing sample", path: "pkg/sampler.go", text: `clientSecret := "customer-prod-secret"`, want: false},
{name: "provider token under weak key", path: "docs/config.yaml", text: `token: ` + providerToken, want: true},
{name: "provider token under hash key", path: "docs/config.yaml", text: `access_token_hash: ` + providerToken, want: true},
{name: "high entropy strong secret", path: "docs/config.yaml", text: `client_secret: ` + highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got []Finding
if tt.comment {
got = ScanComment("issue_comment", tt.text)
} else {
got = ScanFile(tt.path, []byte(tt.text+"\n"))
}
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileClassifiesLowEvidenceTestFixtureCredentials(t *testing.T) {
providerToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
highEntropyValue := "Q7k2mN9pR4vX8cL3" + "sT6yU1aD5fG0hJ2z"
tests := []struct {
name string
value string
want bool
}{
{name: "human readable access token", value: "user-access-token", want: false},
{name: "delimited secret value", value: "secret-value", want: false},
{name: "underscored secret fixture", value: "plain_secret", want: false},
{name: "short delimited fixture", value: "t-abc", want: false},
{name: "embedded test marker", value: "perm-grant-test-secret-skip", want: false},
{name: "real labeled fixture", value: "real-token", want: false},
{name: "ambiguous concrete word", value: "supersecret", want: false},
{name: "provider token", value: providerToken, want: true},
{name: "high entropy secret", value: highEntropyValue, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(`AppSecret: "`+tt.value+`"`+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != tt.want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, tt.want, got)
}
})
}
}
func TestScanFileAllowsLowEvidenceTestFixtureAssignmentSyntaxes(t *testing.T) {
got := ScanFile("pkg/config_test.go", []byte(strings.Join([]string{
`secret := "secret-value"`,
`samplePassword := "sample-password"`,
`bodyWithToken := "plain text body\\nDownload: https://example.com/file?token=tok_aaa\\n"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("low-evidence test fixture assignment should not be reported: %#v", got)
}
if count != 6 {
t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got)
}
}
@@ -1382,10 +1116,9 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{
`{"client_token":"` + stripeLike + `"}`,
`{"client_token":"` + githubToken + `"}`,
`{"client_token":"real-client-secret-value"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1419,10 +1152,9 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) {
func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{
`{ "resource_token": "` + stripeLike + `" }`,
`{ "block_token": "` + githubToken + `" }`,
`{ "block_token": "real-client-secret-value" }`,
}, "\n")+"\n"))
var count int
for _, item := range got {
@@ -1636,43 +1368,39 @@ func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) {
}
}
func TestScanFileAllowsInvalidProviderPlaceholderLookalikes(t *testing.T) {
func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"client_secret: " + stripeLike + "_HERE",
"api_key: YOUR_" + stripeLike,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("invalid provider placeholder lookalike should not be blocked: %#v", got)
count++
}
}
if count != 2 {
t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got)
}
}
func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) {
stripeLike := "sk_" + "live_1234567890abcdef"
patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234"
cases := []struct {
name string
text string
want bool
}{
{name: "stripe", text: "CLIENT_SECRET=%" + stripeLike + "%", want: true},
{name: "github", text: "GITHUB_TOKEN=%" + patLike + "%", want: true},
{name: "readable", text: "TOKEN=%real-secret-token-value%", want: false},
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
"CLIENT_SECRET=%" + stripeLike + "%",
"GITHUB_TOKEN=%" + patLike + "%",
"TOKEN=%real-secret-token-value%",
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertGenericCredentialFinding(t, "docs/config.md", tc.text, tc.want)
})
}
}
func assertGenericCredentialFinding(t *testing.T, file, text string, want bool) {
t.Helper()
got := ScanFile(file, []byte(text+"\n"))
if actual := findingRules(got)["public_content_generic_credential"]; actual != want {
t.Fatalf("generic credential finding = %v, want %v: %#v", actual, want, got)
if count != 3 {
t.Fatalf("percent-wrapped credential findings = %d, want 3: %#v", count, got)
}
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -14,7 +15,6 @@ import (
qdiff "github.com/larksuite/cli/internal/qualitygate/diff"
"github.com/larksuite/cli/internal/qualitygate/manifest"
"github.com/larksuite/cli/internal/qualitygate/report"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/internal/vfs"
)
@@ -203,8 +203,7 @@ func TestRunCollectsPublicContentFindingsIntoDiagnosticsAndFacts(t *testing.T) {
if err := vfs.MkdirAll(filepath.Join(repo, "docs"), 0o755); err != nil {
t.Fatal(err)
}
providerValue := "ghp_" + "1234567890abcdef1234567890abcdef1234"
publicDoc := "api_" + "key = \"" + providerValue + "\"\n" +
publicDoc := "api_" + "key = \"example-public-key\"\n" +
"Public docs describe a pri" + "vate request header and trust classification detail.\n"
if err := vfs.WriteFile(filepath.Join(repo, "docs", "public.md"), []byte(publicDoc), 0o644); err != nil {
t.Fatal(err)
@@ -600,8 +599,7 @@ func TestNormalizeDiagnosticFileHandlesAbsoluteRepo(t *testing.T) {
func runGit(t *testing.T, repo string, args ...string) {
t.Helper()
commandArgs := append([]string{"-c", "core.hooksPath=/dev/null"}, args...)
cmd := gitcmd.Command(repo, commandArgs...)
cmd := exec.Command("git", append([]string{"-c", "core.hooksPath=/dev/null", "-C", repo}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_DATE=2026-06-17T00:00:00Z", "GIT_COMMITTER_DATE=2026-06-17T00:00:00Z")
out, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package gitcmd provides Git process helpers for tests that use temporary
// repositories.
package gitcmd
import (
"os"
"os/exec"
"strconv"
"testing"
)
const (
maintenanceAutoDetach = "maintenance.autoDetach"
gcAutoDetach = "gc.autoDetach"
)
// Command creates a Git command whose automatic maintenance stays in the
// command lifecycle, so temporary repository cleanup cannot race a detached
// maintenance process.
func Command(dir string, args ...string) *exec.Cmd {
commandArgs := make([]string, 0, len(args)+4)
commandArgs = append(commandArgs,
"-c", maintenanceAutoDetach+"=false",
"-c", gcAutoDetach+"=false",
)
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd.Dir = dir
return cmd
}
// SetSynchronousMaintenanceEnv applies the same lifecycle contract to every
// Git process started by the current test, including processes created through
// production command runners. Tests using it must not run in parallel.
func SetSynchronousMaintenanceEnv(t *testing.T) {
t.Helper()
count := 0
if value, ok := os.LookupEnv("GIT_CONFIG_COUNT"); ok {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 0 {
t.Fatalf("invalid GIT_CONFIG_COUNT %q", value)
}
count = parsed
}
for _, key := range []string{maintenanceAutoDetach, gcAutoDetach} {
index := strconv.Itoa(count)
t.Setenv("GIT_CONFIG_KEY_"+index, key)
t.Setenv("GIT_CONFIG_VALUE_"+index, "false")
count++
}
t.Setenv("GIT_CONFIG_COUNT", strconv.Itoa(count))
}

View File

@@ -1,47 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package gitcmd
import (
"os/exec"
"strings"
"testing"
)
func TestCommandDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
cmd := Command(t.TempDir(), "config", "--get", "--type=bool", key)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestSetSynchronousMaintenanceEnv(t *testing.T) {
t.Setenv("GIT_CONFIG_COUNT", "1")
t.Setenv("GIT_CONFIG_KEY_0", "user.name")
t.Setenv("GIT_CONFIG_VALUE_0", "Existing Test User")
SetSynchronousMaintenanceEnv(t)
for key, want := range map[string]string{
"user.name": "Existing Test User",
maintenanceAutoDetach: "false",
gcAutoDetach: "false",
} {
cmd := exec.Command("git", "config", "--get", "--type=bool", key)
if key == "user.name" {
cmd = exec.Command("git", "config", "--get", key)
}
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config %s: %v\n%s", key, err, out)
}
if got := strings.TrimSpace(string(out)); got != want {
t.Fatalf("%s = %q, want %q", key, got, want)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -34,12 +34,7 @@ func writeFixture(t *testing.T, files fixtureRepo) string {
func runGit(t *testing.T, root string, args ...string) string {
t.Helper()
commandArgs := []string{
"-c", "maintenance.autoDetach=false",
"-c", "gc.autoDetach=false",
}
commandArgs = append(commandArgs, args...)
cmd := exec.Command("git", commandArgs...)
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
@@ -48,14 +43,6 @@ func runGit(t *testing.T, root string, args ...string) string {
return strings.TrimSpace(string(out))
}
func TestRunGitDisablesDetachedMaintenance(t *testing.T) {
for _, key := range []string{"maintenance.autoDetach", "gc.autoDetach"} {
if got := runGit(t, t.TempDir(), "config", "--get", "--type=bool", key); got != "false" {
t.Fatalf("%s = %q, want false", key, got)
}
}
}
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
root := writeFixture(t, fixtureRepo{
"errs/subtypes.go": `package errs

View File

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

View File

@@ -1,469 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
const automationSkillDoc = "../../skills/lark-apps/references/lark-apps-automation.md"
const localDevSkillDoc = "../../skills/lark-apps/references/lark-apps-local-dev.md"
const larkAppsSkillDoc = "../../skills/lark-apps/SKILL.md"
const releaseGetSkillDoc = "../../skills/lark-apps/references/lark-apps-release-get.md"
func readAutomationSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, automationSkillDoc)
}
func readLocalDevSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, localDevSkillDoc)
}
func readReleaseGetSkillDoc(t *testing.T) string {
return readAppsSkillDoc(t, releaseGetSkillDoc)
}
func readAppsSkillDoc(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read skill doc %s: %v", path, err)
}
return string(raw)
}
func skillSection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start < 0 {
t.Fatalf("missing skill section %q", heading)
}
rest := doc[start+len(heading):]
if next := strings.Index(rest, "\n## "); next >= 0 {
return rest[:next]
}
return rest
}
func skillSubsection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start < 0 {
t.Fatalf("missing skill subsection %q", heading)
}
rest := doc[start+len(heading):]
end := len(rest)
for _, marker := range []string{"\n### ", "\n## "} {
if next := strings.Index(rest, marker); next >= 0 && next < end {
end = next
}
}
return rest[:end]
}
func requireInOrder(t *testing.T, text string, tokens ...string) {
t.Helper()
offset := 0
for _, token := range tokens {
idx := strings.Index(text[offset:], token)
if idx < 0 {
t.Fatalf("missing %q after %q", token, text[:offset])
}
offset += idx + len(token)
}
}
func requireFirstOccurrencesInOrder(t *testing.T, text string, tokens ...string) {
t.Helper()
previous := -1
for _, token := range tokens {
idx := strings.Index(text, token)
if idx < 0 {
t.Fatalf("missing %q", token)
}
if idx <= previous {
t.Fatalf("first %q at %d must follow the previous contract token at %d", token, idx, previous)
}
previous = idx
}
}
func TestAutomationSkillContract_ChangedHandlerStartWaitsForThisRelease(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 实现或更新 handler 后发布并启动/测试")
requireInOrder(t, section,
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler",
"+automation-get",
"记录发布前状态",
"--name",
"项目 guide",
"按项目 guide 完成同名业务 handler 并本地验证。",
"在 Git 已确认/预授权时 commit然后执行",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"确认 disabled",
"+release-create --branch sprint/default",
"data.release_id",
"+release-get",
"data.status=finished",
"仅启动",
"+automation-enable",
"+automation-get",
"不制造 runtime probe",
"测试",
"运行时验证的操作级授权",
"完成全部 preflight",
"才执行 `+automation-enable`",
"真实 runtime",
"仅要求测试",
"恢复到发布前状态",
)
requireFirstOccurrencesInOrder(t, section,
"+automation-get",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"+release-create --branch sprint/default",
"data.status=finished",
"仅启动",
)
for _, boundary := range []string{
"仅当本轮确实需要新增或修改 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` handler且用户要求把这次代码发布后启动或测试时才使用此路径。",
"按项目 guide 完成同名业务 handler 并本地验证。",
"在 Git 已确认/预授权时 commit然后执行 `git push origin sprint/default`。",
"若该命令本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release新代码未上线原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled 后停止;若因超时等导致结果未知,保持 disabled先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
"只有 `data.status=finished` 才能继续;`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟。",
"确认 `failed` 时报告发布失败,原本 enabled 的 trigger 仅在确认新代码未上线后恢复 enabled原本 disabled 的保持 disabled。",
"发布状态仍不确定时不得进入 enable、probe 或状态恢复分支。",
"**仅启动**:取得持续启动授权后执行 `+automation-enable`,并用 `+automation-get` 确认 enabled到此结束不制造 runtime probe。",
"**测试(含“启动并测试”)**:先按下节“运行时验证的操作级授权”完成全部 preflight",
"若用户仅要求测试而不是持续启动,只在本轮 release 已 `finished` 且 probe 成功后恢复到发布前状态",
"无论用户是仅测试还是启动并测试probe 失败、结果不确定或 enable 后提前结束时,一律 `+automation-disable` 并回读 disabled",
"不得把“发布前 enabled”当作失败后的恢复依据",
"没有通用的 `automation-debug` 或 trigger 日志 shortcut。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("complete-start section must explain %q boundary", boundary)
}
}
}
func TestAutomationSkillContract_BindsTheExactNameAsUser(t *testing.T) {
doc := readAutomationSkillDoc(t)
for _, boundary := range []string{
"全部操作需 `--as user`AuthType: user。",
"当用户希望触发器实际执行业务代码时,先确认当前工作区是已初始化的应用项目,并读取其中与触发器任务匹配的 guide。",
"`--name` 是应用内唯一的 trigger 定位键;代码侧绑定名称必须与它逐字相同。不得用 trigger ID 或方法名代替它。具体 handler 语法和接入方式以项目 guide 为准。",
} {
if !strings.Contains(doc, boundary) {
t.Errorf("automation skill must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_RoutesAndDiagnosesUnfiredTriggers(t *testing.T) {
doc := readAutomationSkillDoc(t)
routeSection := skillSection(t, doc, "## 何时用本 skill路由锚点")
errorSection := skillSection(t, doc, "## 常见错误与决策场景")
if !strings.Contains(routeSection, "「触发器没反应 / enable 了不触发 / 为什么没执行 / 验证一下触发器」→ 先按「未触发时的诊断顺序」诊断;对 UPSERT 和 feishu-approval 仅验证配置边界,不承诺 handler 或 live 验证。") {
t.Error("routing anchors must direct unfired triggers to the bounded diagnostic flow")
}
if !strings.Contains(errorSection, "已证实的 cron、webhook、record-changeINSERT/UPDATE/DELETE按「未触发时的诊断顺序」排查UPSERT 和 feishu-approval 仅核对配置边界,不承诺 handler 或 live 验证。") {
t.Error("error table must preserve the bounded unfired-trigger diagnostic flow")
}
}
func TestAutomationSkillContract_ConfigurationStopsDisabled(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅创建/配置触发器")
for _, boundary := range []string{
"用 `+automation-create` 创建,并省略 `--status` 或显式传 `disabled`,然后报告 name 和 disabled 状态。",
"不要传 `--status enabled`,也不要写 handler、commit/push、release 或 enable更不能把创建 API 成功称为“可运行”。",
"默认 disabled 是这个意图的终点,不是稍后自动 enable 的待办。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("configuration-only section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_EnableExistingTriggerDoesNotPublish(t *testing.T) {
doc := readAutomationSkillDoc(t)
section := skillSubsection(t, doc, "### 仅启用已有 disabled trigger")
routeSection := skillSection(t, doc, "## 何时用本 skill路由锚点")
requireInOrder(t, section,
"用户只要求启用已存在且 disabled 的 trigger",
"+automation-get",
"+release-list --status finished --page-size 1",
"已完成线上 release",
"当前线上应用",
"不能证明该 trigger name 已绑定 handler",
"+automation-enable",
"+automation-get",
"不得修改 handler、commit/push 或 release",
"对 UPSERT 或 feishu-approval 只改变配置状态",
)
if !strings.Contains(section, "未发布时不得自动创建 release也不得声称 trigger 已开始实际运行") {
t.Error("enable-only flow must distinguish configuration enablement from a published runtime")
}
if !strings.Contains(section, "即使存在 finished release也只能把 enable 报告为配置激活") {
t.Error("enable-only flow must not infer handler provenance from app release history")
}
if strings.Contains(section, "apps +get") || strings.Contains(section, "`is_published`") {
t.Error("enable-only flow must use finished release history instead of an optional app detail field")
}
for _, forbidden := range []string{"git push", "+release-create"} {
if strings.Contains(section, forbidden) {
t.Errorf("enable-only flow must not contain %q", forbidden)
}
}
if !strings.Contains(routeSection, "「启用 / 启动已有 trigger」→ 先核对现有状态;只启用时不要修改源码或发布应用。") {
t.Error("routing anchors must keep existing-trigger enablement separate from code release")
}
}
func TestAutomationSkillContract_TestExistingTriggerDoesNotPublish(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 测试已有线上 trigger不改代码")
requireInOrder(t, section,
"用户要求测试已经发布的 trigger",
"+automation-get",
"+release-list --status finished --page-size 1",
"当前线上代码",
"不得为测试自动修改源码、commit/push 或 release",
"在任何临时 enable 之前完成",
"测试请求已明确包含临时 enable或另行取得 enable 授权",
"运行时验证的操作级授权",
"无论 probe 成功、失败、结果不确定,还是临时 enable 后提前结束或中断,最终都必须 `+automation-disable` 并回读 disabled",
)
for _, forbidden := range []string{"git push", "+release-create"} {
if strings.Contains(section, forbidden) {
t.Errorf("existing-trigger test flow must not contain %q", forbidden)
}
}
}
func TestAutomationSkillContract_HandlerOnlyStopsBeforeRelease(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler不发布/不启用)")
for _, boundary := range []string{
"创建或定位已明确 name 的 disabled trigger读取项目 guide按其要求实现同名业务 handler完成本地验证。",
"只在既有 Git 确认或预授权下 commit/push停止在 `+release-create` 和 `+automation-enable` 之前。",
"用户没有明确“发布好”时,先问,不能默认把完整应用上线。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("handler-only section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_HandlerOnlyExcludesUnverifiedRuntimeTypes(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 仅完成 handler不发布/不启用)")
if !strings.Contains(section, "仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。") {
t.Error("handler-only flow must exclude UPSERT and feishu-approval without a verified runtime contract")
}
}
func TestAutomationSkillContract_PublishedHandlerStaysDisabled(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 把 handler 发布好,但先不要启动")
for _, boundary := range []string{
"仅对 cron、webhook、record-change 的 `INSERT`、`UPDATE`、`DELETE` 使用此路径。",
"先用 `+automation-get` 定位;不存在时用 `+automation-create` 创建同名 disabled trigger再次回读确认。",
"已存在时记录它是否 enabled。",
"若 trigger 已 enabled先说明发布前必须临时停用以及可能造成的运行中断并取得这次临时停用授权未获授权时停止在发布前。",
"取得授权后,在发布前执行 `+automation-disable`,并再次用 `+automation-get` 确认 disabled。",
"按项目 guide 完成同名业务 handler 并本地验证后commit、`git push origin sprint/default`。",
"随后发布完整应用:",
"若 `+release-create` 本身返回错误或未返回 `data.release_id`:视为确认未创建本轮 release新代码未上线原本 enabled 的 trigger 恢复 enabled 并回读、原本 disabled 的保持 disabled然后停止若因超时等导致创建结果未知保持 disabled先用 `+release-list --status finished --page-size 1` 核对是否已产生新 release 再决定。",
"取得 `data.release_id` 后,对**这一轮** ID 调用 `+release-get``publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时且状态仍不确定时报告 `release_id` 和当前 status并保持 disabled只有 `data.status=finished` 才算完成。",
"确认 `failed` 且新代码未上线时,原本 enabled 的 trigger 恢复 enabled 并回读,原本 disabled 的保持 disabled。",
"release 是整个应用上线可能影响既有线上功能未获得启动或测试授权时finished 后始终保持 disabled不执行 `+automation-enable`。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("publish-without-start section must preserve %q", boundary)
}
}
requireFirstOccurrencesInOrder(t, section,
"+automation-get",
"git push origin sprint/default",
"临时停用授权",
"+automation-disable",
"+release-create",
)
}
func TestAutomationSkillContract_UPSERTAndApprovalStayConfigurationOnly(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### UPSERT 与飞书审批边界")
for _, boundary := range []string{
"record-change 的 UPSERT 可创建 disabled 配置,但当前没有已证实的运行时代码契约;不得静默按 UPDATE 处理,也不得承诺 handler 或 live 验证。",
"feishu-approval 可创建 disabled 配置,并读取或更新 `event_type`、对应 status 和可选 `approval_code`。",
"当前没有已证实的运行时 handler 契约或实际投递验证;不要把 enable 或审批 API 成功称为业务代码已执行。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("UPSERT/approval boundary section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_RuntimeProbeRequiresOperationScope(t *testing.T) {
section := skillSubsection(t, readAutomationSkillDoc(t), "### 运行时验证的操作级授权")
for _, boundary := range []string{
"启用 trigger 的授权不等于制造 runtime 事件的授权,测试授权也不等于任意数据库写入授权。",
"record-change 在执行任何 DML 前,必须明确并取得覆盖以下作用域的授权",
"环境、表、操作、精确测试记录或筛选条件、payload、预期结果和清理方式",
"优先使用专用测试记录",
"`DELETE`",
"[lark-apps-db-execute.md](lark-apps-db-execute.md)",
"先 `SELECT count(*)`、执行 `--dry-run`",
"取得针对该删除目标的明确授权",
"+automation-list --trigger-type record-change --all",
"同一环境、表和操作可能命中的其他 enabled trigger",
"聚合业务影响",
"恢复 UPDATE 或清理 INSERT 也可能再次触发自动化",
"缺少安全、已授权且可清理的事件入口时,记录 blocked",
} {
if !strings.Contains(section, boundary) {
t.Errorf("runtime probe section must preserve %q", boundary)
}
}
}
func TestAutomationSkillContract_UsesResolvableSharedSkillLink(t *testing.T) {
doc := readAutomationSkillDoc(t)
if strings.Contains(doc, "](../lark-shared/SKILL.md)") {
t.Error("automation reference must not resolve lark-shared inside the lark-apps directory")
}
if !strings.Contains(doc, "](../../lark-shared/SKILL.md)") {
t.Error("automation reference must link to the sibling lark-shared skill")
}
sharedSkillDoc := filepath.Clean(filepath.Join(filepath.Dir(automationSkillDoc), "../../lark-shared/SKILL.md"))
if _, err := os.Stat(sharedSkillDoc); err != nil {
t.Fatalf("automation reference target %s must exist: %v", sharedSkillDoc, err)
}
}
func TestAppsSkillContract_AllSharedSkillLinksResolve(t *testing.T) {
docs := []string{larkAppsSkillDoc}
references, err := filepath.Glob("../../skills/lark-apps/references/*.md")
if err != nil {
t.Fatalf("glob lark-apps references: %v", err)
}
docs = append(docs, references...)
sharedLink := regexp.MustCompile(`\]\(([^)]+lark-shared/SKILL\.md)\)`)
for _, docPath := range docs {
doc := readAppsSkillDoc(t, docPath)
for _, match := range sharedLink.FindAllStringSubmatch(doc, -1) {
target := filepath.Clean(filepath.Join(filepath.Dir(docPath), match[1]))
if _, err := os.Stat(target); err != nil {
t.Errorf("%s shared-skill link %q resolves to missing target %s: %v", docPath, match[1], target, err)
}
}
}
}
func TestLocalDevSkillContract_UsesProjectGuideWithoutSyncInternals(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## Trigger guide 的项目边界")
for _, boundary := range []string{
"先查看工作区 `.agents/skills/`,读取与自动化任务匹配的 `trigger-guide`。",
"文件缺失或不能覆盖当前任务时,报告项目缺少可用的领域 guide不要在本 lark-cli reference 中猜测安装命令、版本或包内目录。",
} {
if !strings.Contains(section, boundary) {
t.Errorf("trigger-guide boundary section must explain %q", boundary)
}
}
for _, implementationShape := range []string{
"npx ", "skills sync", "data.", "skills_", "_CACHE_DIR", "nestjs-",
"@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
} {
if strings.Contains(section, implementationShape) {
t.Errorf("local-dev skill must not expose project-sync implementation shape %q", implementationShape)
}
}
}
func TestAppsSkillContract_DoesNotExposeSteeringImplementation(t *testing.T) {
for name, doc := range map[string]string{
"automation": readAutomationSkillDoc(t),
"local-dev": readLocalDevSkillDoc(t),
} {
for _, implementationShape := range []string{
"npx ", "skills sync", "@lark-apaas/miaoda-cli", "@lark-apaas/coding-steering", "miaoda-coding", "skills_common/",
} {
if strings.Contains(doc, implementationShape) {
t.Errorf("%s skill must not expose project-sync implementation shape %q", name, implementationShape)
}
}
}
}
func TestLocalDevSkillContract_UsesEnvironmentAndDefersEnableToAutomationSOP(t *testing.T) {
doc := readLocalDevSkillDoc(t)
releaseSection := skillSection(t, doc, "## 改完代码后部署上线")
for _, legacy := range []string{"--env dev", "--env online"} {
if strings.Contains(doc, legacy) {
t.Errorf("local-dev skill must not recommend legacy %q", legacy)
}
}
for _, boundary := range []string{
"`publishing` 时每 20 秒继续轮询,整体最多约 5 分钟;超时仍未完成时停止本轮轮询、报告 `release_id` 和当前 status。",
"若本次改动包含自动化 handler在执行本节通用 commit/push/release 序列前就转到 [automation SOP](lark-apps-automation.md) 的匹配路径,由该 SOP 负责完整的状态门禁、commit/push、release 和可选 enable/test不要先按本节发布再补 trigger 状态检查。",
"用户只要求启用已有 trigger 时,转到 [automation SOP 的「仅启用已有 disabled trigger」路径](lark-apps-automation.md#仅启用已有-disabled-trigger);不得因 enable 反向修改 handler、commit/push 或 release。",
"使用 `--environment dev|online`,不要使用旧的 `--env`。只有确认应用已开启多环境时才引导 `--environment dev`;单环境应用省略 `--environment`(服务端选 online或显式传 `--environment online`。",
} {
if !strings.Contains(doc, boundary) {
t.Errorf("local-dev skill must preserve %q", boundary)
}
}
routeIndex := strings.Index(releaseSection, "若本次改动包含自动化 handler")
releaseIndex := strings.Index(releaseSection, "+release-create")
if routeIndex < 0 || releaseIndex < 0 || routeIndex >= releaseIndex {
t.Error("automation routing must appear before the generic release sequence")
}
}
func TestLocalDevSkillContract_DoesNotRequireOnlineURL(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
if strings.Contains(section, "`finished` 成功时该命令输出已含 `online_url`") {
t.Error("release guidance must not claim every finished release includes online_url")
}
if !strings.Contains(section, "若返回 `online_url`,可直接使用;未返回时不要编造链接。") {
t.Error("release guidance must explain that online_url is optional")
}
}
func TestLocalDevSkillContract_TreatsErrorLogsAsOptional(t *testing.T) {
section := skillSection(t, readLocalDevSkillDoc(t), "## 改完代码后部署上线")
if !strings.Contains(section, "`failed` 时若返回非空 `error_logs`,据此给出失败原因;否则只报告 `release_id` 和当前 status不要编造原因") {
t.Error("release guidance must not promise error_logs on every failed release")
}
}
func TestReleaseSkillContract_TreatsOptionalOutputAsOptional(t *testing.T) {
releaseGet := readReleaseGetSkillDoc(t)
for _, boundary := range []string{
"`finished` 后才可能有 `online_url`。",
"若输出含 `online_url`,直接读取它作为本轮发布的线上访问链接;未返回时只报告发布完成,不要编造链接。",
"若输出含 `error_logs``step`/`error_log`),据此向用户转述关键失败步骤和可行动修复;未返回时不要编造失败原因。",
} {
if !strings.Contains(releaseGet, boundary) {
t.Errorf("release-get skill must preserve optional-output boundary %q", boundary)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
)
func appsValidationError(format string, args ...any) *errs.ValidationError {
@@ -73,3 +74,32 @@ func appsInputPathEntryError(path string, err error) error {
func appsFileIOError(err error, format string, args ...any) *errs.InternalError {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err)
}
// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish
// endpoint: refines endpoint-scoped business codes, prefixes the message with
// command context, and attaches endpoint-specific recovery hints. A
// still-untyped error is lifted at the SDK boundary instead.
func enrichHTMLPublishAPIError(err error) error {
if err == nil {
return nil
}
p, ok := errs.ProblemOf(err)
if !ok {
return client.WrapDoAPIError(err)
}
// The HTML publish business codes (90001/90002) are scoped to this
// endpoint, not service-global, so their subtype classification lives
// here instead of the global errclass code table. Only an
// otherwise-unclassified API error is refined; a stronger upstream
// classification is never overridden.
if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound {
p.Subtype = errs.SubtypeNotFound
}
if p.Message != "" {
p.Message = "html-publish failed: " + p.Message
}
if hint := buildHTMLPublishFailureHint(p.Code); hint != "" {
p.Hint = hint
}
return err
}

View File

@@ -57,3 +57,57 @@ func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) {
t.Fatalf("cause chain not preserved: %v", err)
}
}
func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) {
err := enrichHTMLPublishAPIError(errors.New("connection reset by peer"))
problem := requireAppsProblem(t, err, errs.CategoryNetwork)
if problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport)
}
}
func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "build failed").
WithCode(errCodeBuildFailed).
WithLogID("logid-build-failed")
got := enrichHTMLPublishAPIError(err)
if got != err {
t.Fatalf("typed error should be enriched in place")
}
problem := requireAppsAPIProblem(t, got)
if problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown)
}
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.LogID != "logid-build-failed" {
t.Fatalf("log_id = %q, want preserved", problem.LogID)
}
if !strings.Contains(problem.Message, "html-publish failed") {
t.Fatalf("message = %q, want html-publish context", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("expected known-code recovery hint")
}
}
func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
}
func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound)
problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err))
if problem.Subtype != errs.SubtypeRateLimit {
t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit)
}
}

View File

@@ -17,11 +17,10 @@ import (
var AppsGet = common.Shortcut{
Service: appsService,
Command: "+get",
Description: "Get a single app's detail by app ID or meta token (returns app_type, name, description, publish status, etc.)",
Description: "Get a single app's detail by app ID (returns app_type, name, description, publish status, etc.)",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +get --app-id <app_id>",
"Example: lark-cli apps +get --app-id <meta_token>",
"Example: lark-cli apps +get --app-id <app_id> --dry-run",
"Tip: extract app type with --jq '.data.app.app_type'",
},
@@ -29,7 +28,7 @@ var AppsGet = common.Shortcut{
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "app ID or meta token", Required: true},
{Name: "app-id", Desc: "app ID", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
@@ -41,7 +40,7 @@ var AppsGet = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))).
Desc("Get app detail (returns app_id, meta_token, app_type, name, description, icon_url, created_at, updated_at, is_published)")
Desc("Get app detail (returns app_id, app_type, name, description, icon_url, created_at, updated_at, is_published)")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
@@ -55,9 +54,6 @@ var AppsGet = common.Shortcut{
return
}
fmt.Fprintf(w, "app_id: %v\n", app["app_id"])
if mt, ok := app["meta_token"].(string); ok && mt != "" {
fmt.Fprintf(w, "meta_token: %s\n", mt)
}
fmt.Fprintf(w, "app_type: %v\n", app["app_type"])
fmt.Fprintf(w, "name: %v\n", app["name"])
if desc, ok := app["description"].(string); ok && desc != "" {

View File

@@ -14,6 +14,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,13 +38,9 @@ var AppsHTMLPublish = common.Shortcut{
{Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
path := strings.TrimSpace(rctx.Str("path"))
if path == "" {
return appsValidationParamError("--path", "--path is required")
@@ -76,11 +73,9 @@ var AppsHTMLPublish = common.Shortcut{
appID := strings.TrimSpace(rctx.Str("app-id"))
path := strings.TrimSpace(rctx.Str("path"))
dry := common.NewDryRunAPI()
dry.Desc("Pack tar.gz → GET pre_release for TOS upload URL → PUT tar.gz to TOS → POST release-create with tos_path; returns release_id")
dry.GET(fmt.Sprintf("%s/apps/%s/pre_release", apiBasePath, validate.EncodePathSegment(appID))).
PUT("<presigned_upload_url> (from pre_release response)").
POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))).
Body(map[string]string{"tos_path": "<from pre_release response>"})
dry.Desc("Pack tar.gz and publish HTML app (actual API path determined at runtime by app type; returns url or release_id)")
dry.POST(fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID))).
Set("content_type", "multipart/form-data")
candidates, err := walkHTMLPublishCandidates(rctx.FileIO(), path)
if err != nil {
@@ -128,7 +123,16 @@ var AppsHTMLPublish = common.Shortcut{
Path: strings.TrimSpace(rctx.Str("path")),
}
out, err := runHTMLPublishTOS(ctx, rctx, spec)
appType := queryAppType(ctx, rctx, spec.AppID)
var out map[string]interface{}
var err error
if appType == "modern_html" {
out, err = runHTMLPublishTOS(ctx, rctx, spec)
} else {
client := appsHTMLPublishAPI{runtime: rctx}
out, err = runHTMLPublish(ctx, rctx.FileIO(), client, spec)
}
if err != nil {
return err
}
@@ -260,7 +264,25 @@ func prepareHTMLPublishTarball(fio fileio.FileIO, path string) (*htmlPublishTarb
return tarball, nil
}
// runHTMLPublishTOS handles the publish path: validate → tar.gz →
func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) {
tarball, err := prepareHTMLPublishTarball(fio, spec.Path)
if err != nil {
return nil, err
}
resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball)
if err != nil {
return nil, client.WrapDoAPIError(err)
}
out := map[string]interface{}{}
if resp.URL != "" {
out["url"] = resp.URL
}
return out, nil
}
// runHTMLPublishTOS handles the modern_html publish path: validate → tar.gz →
// call pre_release to get TOS upload URL → upload tar.gz to TOS → return
// tos_path for +release-create --tos-path.
func runHTMLPublishTOS(ctx context.Context, rctx *common.RuntimeContext, spec appsHTMLPublishSpec) (map[string]interface{}, error) {

View File

@@ -5,6 +5,7 @@ package apps
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -22,6 +23,20 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
type fakeAppsHTMLPublishClient struct {
resp *htmlPublishResponse
err error
calls []string
}
func (f *fakeAppsHTMLPublishClient) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
f.calls = append(f.calls, appID)
if f.err != nil {
return nil, f.err
}
return f.resp, nil
}
func writeAppsSampleSite(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -31,19 +46,71 @@ func writeAppsSampleSite(t *testing.T) string {
return dir
}
func TestPrepareHTMLPublishTarball_PathNotFound(t *testing.T) {
_, err := prepareHTMLPublishTarball(newTestFIO(), "/nonexistent")
if err == nil {
t.Fatalf("expected error")
func TestRunHTMLPublish_HappyPath(t *testing.T) {
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if out["url"] != "https://miaoda/app_x" {
t.Fatalf("url=%v", out["url"])
}
if len(fake.calls) != 1 || fake.calls[0] != "app_x" {
t.Fatalf("calls=%v", fake.calls)
}
}
func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
func TestRunHTMLPublish_OnlyURLInEnvelope(t *testing.T) {
// Pin 概要设计 §5.3 不变量 4 "同步语义不会变成异步" (legacy html path only):
// envelope 只含 url未来若有人加 status / release_id 字段会被这个测试拦截。
site := writeAppsSampleSite(t)
fake := &fakeAppsHTMLPublishClient{
resp: &htmlPublishResponse{URL: "https://miaoda/app_x"},
}
out, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if err != nil {
t.Fatalf("err=%v", err)
}
if len(out) != 1 {
t.Fatalf("envelope should only contain 'url', got %d keys: %v", len(out), out)
}
if _, ok := out["url"]; !ok {
t.Fatalf("envelope missing 'url': %v", out)
}
}
func TestRunHTMLPublish_ClientErrorPropagated(t *testing.T) {
site := writeAppsSampleSite(t)
wantErr := errors.New("server timeout")
fake := &fakeAppsHTMLPublishClient{err: wantErr}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: site})
if !errors.Is(err, wantErr) {
t.Fatalf("err=%v", err)
}
}
func TestRunHTMLPublish_PathNotFound(t *testing.T) {
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: "/nonexistent"})
if err == nil {
t.Fatalf("expected error")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when path invalid")
}
}
func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) {
// 目录形态:缺 index.html 应该被拦
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "foo.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected error for missing index.html")
}
@@ -54,9 +121,13 @@ func TestPrepareHTMLPublishTarball_DirRequiresIndexHTML(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when index.html missing")
}
}
func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
func TestRunHTMLPublish_DirWithIndexHTMLPasses(t *testing.T) {
// 目录含 index.html 应该正常走完
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
@@ -64,49 +135,57 @@ func TestPrepareHTMLPublishTarball_DirWithIndexHTMLPasses(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "extra.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err != nil {
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("err=%v", err)
}
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
if len(fake.calls) != 1 {
t.Fatalf("client should be called when index.html present")
}
}
func TestPrepareHTMLPublishTarball_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) {
// 单文件形态:文件名不是 index.html 也要拦
dir := t.TempDir()
single := filepath.Join(dir, "foo.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), single)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single})
if err == nil {
t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)")
}
requireAppsValidationProblem(t, err)
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when index.html missing")
}
}
func TestPrepareHTMLPublishTarball_SingleFileNamedIndexPasses(t *testing.T) {
func TestRunHTMLPublish_SingleFileNamedIndexPasses(t *testing.T) {
// 单文件形态:文件名恰好就是 index.html → 放行
dir := t.TempDir()
single := filepath.Join(dir, "index.html")
if err := os.WriteFile(single, []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write fixture: %v", err)
}
tarball, err := prepareHTMLPublishTarball(newTestFIO(), single)
if err != nil {
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: single}); err != nil {
t.Fatalf("err=%v", err)
}
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
if len(fake.calls) != 1 {
t.Fatalf("client should be called for single index.html")
}
}
func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) {
// 把上限调到 100 字节验证拦截defer 恢复原值避免污染其它测试。
orig := maxHTMLPublishTarballBytes
maxHTMLPublishTarballBytes = 100
defer func() { maxHTMLPublishTarballBytes = orig }()
dir := t.TempDir()
// 写 index.html满足新加的 index 校验)+ 大文件超 100 字节上限。
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
@@ -115,7 +194,8 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected oversize error")
}
@@ -126,6 +206,9 @@ func TestPrepareHTMLPublishTarball_RejectsOversizeTarball(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client should not be called when tarball oversize")
}
}
func TestMaxHTMLPublishTarballBytes_Default(t *testing.T) {
@@ -181,17 +264,8 @@ func TestAppsHTMLPublish_DryRunPrintsManifest(t *testing.T) {
t.Fatalf("dry-run err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/pre_release") {
t.Fatalf("dry-run missing pre_release endpoint: %s", got)
}
if !strings.Contains(got, "presigned_upload_url") {
t.Fatalf("dry-run missing TOS PUT step: %s", got)
}
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/releases") {
t.Fatalf("dry-run missing release-create endpoint: %s", got)
}
if !strings.Contains(got, "tos_path") {
t.Fatalf("dry-run missing tos_path in release-create body: %s", got)
if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code") {
t.Fatalf("dry-run missing endpoint: %s", got)
}
if !strings.Contains(got, "index.html") {
t.Fatalf("dry-run missing file list: %s", got)
@@ -426,7 +500,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake,
appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected raw-size cap to fire")
}
@@ -434,6 +510,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") {
t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message)
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when raw cap hit")
}
}
func TestOversizeHTMLFiles(t *testing.T) {
@@ -476,7 +555,8 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
_, err := prepareHTMLPublishTarball(newTestFIO(), dir)
fake := &fakeAppsHTMLPublishClient{}
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
if err == nil {
t.Fatalf("expected per-file oversize error")
}
@@ -487,9 +567,13 @@ func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
if problem.Hint == "" {
t.Fatalf("expected non-empty hint")
}
if len(fake.calls) != 0 {
t.Fatalf("client must not be called when an HTML file is oversize")
}
}
func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
orig := maxHTMLPublishSingleHTMLFileBytes
maxHTMLPublishSingleHTMLFileBytes = 100
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
@@ -501,12 +585,12 @@ func TestPrepareHTMLPublishTarball_IgnoresOversizeNonHTML(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
tarball, err := prepareHTMLPublishTarball(newTestFIO(), dir)
if err != nil {
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
}
if tarball == nil || tarball.Size == 0 {
t.Fatalf("expected non-empty tarball")
if len(fake.calls) != 1 {
t.Fatalf("client should be called; calls=%v", fake.calls)
}
}

View File

@@ -74,18 +74,15 @@ type appTypePolicy struct {
// skipSkillsSync skips the conditional `npx ... skills sync --local` step on
// the non-empty (`app sync`) scaffold path.
skipSkillsSync bool
// skipAppSync skips `npx ... app sync` on the non-empty repo path.
skipAppSync bool
}
// appTypePolicies maps an app_type to its +init control strategy. Types absent
// from the map get the zero-value policy (install runs, env is pulled, skills
// are synced).
var appTypePolicies = map[string]appTypePolicy{
// modern_html / html are static HTML sites: no dependencies to install,
// no startup env vars to pull, no steering skills to sync, and no app sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
"html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true, skipAppSync: true},
// modern_html is a static HTML site: no dependencies to install, no startup
// env vars to pull, and no steering skills to sync.
"modern_html": {skipInstall: true, skipEnvPull: true, skipSkillsSync: true},
}
// policyForAppType returns the +init control strategy for appType. Unlisted
@@ -125,13 +122,9 @@ var AppsInit = common.Shortcut{
{Name: "source-path", Desc: "path to existing source files (e.g. HTML output from an agent) to incorporate into the initialized project"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
if sp := strings.TrimSpace(rctx.Str("source-path")); sp != "" {
if err := charcheck.RejectControlChars(sp, "--source-path"); err != nil {
return appsValidationParamError("--source-path", "%v", err).WithCause(err)
@@ -341,19 +334,11 @@ func ensureMetaAppID(dir, appID string) error {
// each is not already resolvable from local/global/system config, so a
// developer's existing identity is never overwritten. Each key is handled
// independently (a machine with only user.name set still gets a default email).
func ensureGitIdentity(ctx context.Context, dir, authorName, authorEmail string) error {
name := strings.TrimSpace(authorName)
if name == "" {
name = defaultGitUserName
}
email := strings.TrimSpace(authorEmail)
if email == "" {
email = defaultGitUserEmail
}
if err := ensureGitConfigValue(ctx, dir, "user.name", name); err != nil {
func ensureGitIdentity(ctx context.Context, dir string) error {
if err := ensureGitConfigValue(ctx, dir, "user.name", defaultGitUserName); err != nil {
return err
}
return ensureGitConfigValue(ctx, dir, "user.email", email)
return ensureGitConfigValue(ctx, dir, "user.email", defaultGitUserEmail)
}
// ensureGitConfigValue sets <key>=fallback in the repo-local git config when key
@@ -415,16 +400,13 @@ func runScaffold(ctx context.Context, dir, appID, appType, sourcePath string) (s
}
return scaffoldKindInit, nil
}
policy := policyForAppType(appType)
if !policy.skipAppSync {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "app", "sync"); err != nil {
return "", appsExternalToolError(err, "npx app sync failed: %s", gitErr(stderr, err))
}
if err := ensureMetaAppID(dir, appID); err != nil {
return "", err
}
if !policy.skipSkillsSync && !hasSteeringSkills(dir) {
if !policyForAppType(appType).skipSkillsSync && !hasSteeringSkills(dir) {
if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", "--registry", npmRegistry, miaodaCLIPkg, "skills", "sync", "--local"); err != nil {
return "", appsExternalToolError(err, "npx skills sync failed: %s", gitErr(stderr, err))
}
@@ -454,38 +436,26 @@ func scaffoldInitArgs(appType, appID, sourcePath string) []string {
return base
}
// credentialInitResult holds the fields parsed from +git-credential-init output.
type credentialInitResult struct {
RepositoryURL string
CommitAuthorName string
CommitAuthorEmail string
}
// parseCredentialInitEnvelope extracts fields from a +git-credential-init JSON
// envelope ({"ok":true,"data":{"repository_url":"...","commit_author_name":"...","commit_author_email":"..."}}).
func parseCredentialInitEnvelope(stdout string) (credentialInitResult, error) {
// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON
// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name
// matches the contract emitted by `apps +git-credential-init`.
func parseRepoURLFromEnvelope(stdout string) (string, error) {
var env struct {
OK bool `json:"ok"`
Data struct {
RepositoryURL string `json:"repository_url"`
CommitAuthorName string `json:"commit_author_name"`
CommitAuthorEmail string `json:"commit_author_email"`
RepositoryURL string `json:"repository_url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &env); err != nil {
return credentialInitResult{}, appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
return "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err)
}
if !env.OK {
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init reported failure")
return "", appsSubprocessEnvelopeError("+git-credential-init reported failure")
}
if strings.TrimSpace(env.Data.RepositoryURL) == "" {
return credentialInitResult{}, appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
return "", appsSubprocessEnvelopeError("+git-credential-init returned no repository_url")
}
return credentialInitResult{
RepositoryURL: env.Data.RepositoryURL,
CommitAuthorName: env.Data.CommitAuthorName,
CommitAuthorEmail: env.Data.CommitAuthorEmail,
}, nil
return env.Data.RepositoryURL, nil
}
// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success
@@ -557,10 +527,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return err
}
appType, err := queryAppType(ctx, rctx, appID)
if err != nil {
return err
}
appType := queryAppType(ctx, rctx, appID)
policy := policyForAppType(appType)
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
@@ -628,16 +595,16 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
}
initLogf(rctx, "Issuing repository credentials for %s...", appID)
cred, err := issueCredentials(ctx, rctx, appID)
repoURL, err := issueCredentials(ctx, rctx, appID)
if err != nil {
return err
}
if err := validateRepoURLScheme(cred.RepositoryURL); err != nil {
if err := validateRepoURLScheme(repoURL); err != nil {
return err
}
initLogf(rctx, "Cloning into %s...", dir)
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", cred.RepositoryURL, dir); err != nil {
if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil {
return appsExternalToolError(err, "git clone failed: %s", gitErr(stderr, err))
}
initLogf(rctx, "Checking out %s...", defaultInitBranch)
@@ -645,10 +612,9 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
return appsExternalToolError(err, "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err))
}
// Ensure a committer identity exists before the scaffold commit. Uses the
// author name/email from +git-credential-init when available; falls back
// to lark-cli-bot defaults when the server does not provide them.
if err := ensureGitIdentity(ctx, dir, cred.CommitAuthorName, cred.CommitAuthorEmail); err != nil {
// Ensure a committer identity exists before the scaffold commit; only sets
// repo-local defaults when none is configured (existing identity is kept).
if err := ensureGitIdentity(ctx, dir); err != nil {
return err
}
@@ -677,7 +643,7 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
out := map[string]interface{}{
"app_id": appID,
"repository_url": redactURLCredentials(cred.RepositoryURL),
"repository_url": redactURLCredentials(repoURL),
"branch": defaultInitBranch,
"clone_path": dir,
"scaffold": scaffold,
@@ -755,10 +721,10 @@ func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string
// issueCredentials runs `<self> apps +git-credential-init --app-id <id> --format json`
// and returns the repo_url it reports. Forwards --as when set.
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (credentialInitResult, error) {
func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) {
self, err := os.Executable()
if err != nil {
return credentialInitResult{}, errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(err)
}
args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"}
if as := strings.TrimSpace(rctx.Str("as")); as != "" {
@@ -766,11 +732,11 @@ func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID st
}
stdout, stderr, err := initRunner.Run(ctx, "", self, args...)
if err != nil {
return credentialInitResult{}, appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
return "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)).
WithHint("ensure apps +git-credential-init is available and you are logged in").
WithCause(err)
}
return parseCredentialInitEnvelope(stdout)
return parseRepoURLFromEnvelope(stdout)
}
// commitAndPushIfDirty commits and pushes only when the working tree has

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -111,24 +110,18 @@ func TestDefaultCloneDir(t *testing.T) {
// --- pure-function tests ---
func TestParseRepoURL(t *testing.T) {
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.RepositoryURL != "http://u:t@h/app_x.git" {
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
}
if result.CommitAuthorName != "Alice" {
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
}
if result.CommitAuthorEmail != "alice@example.com" {
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
if url != "http://u:t@h/app_x.git" {
t.Errorf("got %q", url)
}
}
func TestParseRepoURL_Errors(t *testing.T) {
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
if _, err := parseCredentialInitEnvelope(in); err == nil {
if _, err := parseRepoURLFromEnvelope(in); err == nil {
t.Errorf("expected error for %q", in)
}
}
@@ -156,22 +149,6 @@ func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
t.Cleanup(func() { initRunner = orig })
}
func stubAppType(reg *httpmock.Registry, appID, appType string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/" + appID,
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": appID,
"app_type": appType,
},
},
},
})
}
func credInitOK(repoURL string) fakeCallResult {
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
}
@@ -336,8 +313,7 @@ func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -378,8 +354,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
}
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
}
@@ -448,8 +423,7 @@ func TestAppsInit_HappyPathCleanTree(t *testing.T) {
"git status": {}, // clean tree after scaffold -> no commit/push
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -498,8 +472,7 @@ func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
"git status": {stdout: " M file.txt"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -569,8 +542,7 @@ func TestAppsInit_CloneFailure(t *testing.T) {
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
@@ -644,8 +616,7 @@ func TestAppsInit_AsPassthrough(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
@@ -751,7 +722,7 @@ func TestIsEmptyRepo(t *testing.T) {
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
// the stderr buffer, so tests can assert on the +init progress log lines that
// initLogf writes to IO().ErrOut.
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
@@ -761,12 +732,12 @@ func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buf
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdout, stderr, reg := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr, reg
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr
}
func TestAppsInit_Req1_Wording(t *testing.T) {
factory, stdout, _, _ := newAppsExecuteFactoryWithStderr(t)
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
@@ -795,8 +766,7 @@ func TestAppsInit_Req1_Wording(t *testing.T) {
"git status": {},
}}
withFakeRunner(t, f)
factory2, stdout2, stderr2, reg2 := newAppsExecuteFactoryWithStderr(t)
stubAppType(reg2, "app_x", "FULL_STACK")
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
t.Fatalf("run err=%v", err)
@@ -859,8 +829,7 @@ func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -901,8 +870,7 @@ func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -922,8 +890,7 @@ func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
"git status": {stdout: " A .spark/meta.json\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -943,8 +910,7 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected: %v", err)
@@ -963,7 +929,8 @@ func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
// gitMust runs a git command in dir with a real binary, failing the test on error.
func gitMust(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := gitcmd.Command(dir, args...)
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
@@ -979,7 +946,6 @@ func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
// Bare remote so `git push origin sprint/default` succeeds.
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1101,7 +1067,6 @@ func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
remote := t.TempDir()
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
@@ -1324,8 +1289,7 @@ func TestAppsInit_EnvPull_Success(t *testing.T) {
"env-pull": envPullOK("/abs/app_x/.env.local"),
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -1363,8 +1327,7 @@ func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
@@ -1403,8 +1366,7 @@ func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
envFile := filepath.Join(abs, ".env.local")
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -1451,8 +1413,7 @@ func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
}
@@ -1744,15 +1705,13 @@ func TestScaffoldInitArgs_WithAppType(t *testing.T) {
}
func TestPolicyForAppType(t *testing.T) {
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
for _, at := range []string{"modern_html", "html"} {
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
}
// modern_html decouples all control points: skip install, env-pull, skills sync.
if p := policyForAppType("modern_html"); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync {
t.Errorf("modern_html policy = %+v, want all skip flags set", p)
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
@@ -1798,7 +1757,7 @@ func configSetValue(calls [][]string, key string) (string, bool) {
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
@@ -1815,7 +1774,7 @@ func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
if err := ensureGitIdentity(context.Background(), "/repo"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
@@ -1831,7 +1790,7 @@ func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
if err := ensureGitIdentity(context.Background(), "/repo"); err == nil {
t.Error("expected error when git config set fails")
}
}

View File

@@ -13,25 +13,22 @@ import (
)
// queryAppType fetches the app's type string from the server via
// GET /open-apis/spark/v1/apps/{identifier}. The identifier can be either
// an app_id or a meta_token — the server resolves both. The server returns
// uppercase app_type values ("HTML", "FULL_STACK", "MODERN_HTML");
// this function normalizes to lowercase. Returns an error when the API
// is unavailable or the response is malformed — callers must not proceed
// with a fallback type to avoid creating the wrong project scaffold.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, identifier string) (string, error) {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(identifier))
// GET /open-apis/spark/v1/apps/{appID}. The server returns uppercase
// values ("HTML", "FULL_STACK", "MODERN_HTML"); this function normalizes
// to lowercase. Returns "" when the API is unavailable or returns an
// error — callers fall back to legacy behavior.
func queryAppType(ctx context.Context, rctx *common.RuntimeContext, appID string) string {
path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))
data, err := rctx.CallAPITyped("GET", path, nil, nil)
if err != nil {
return "", err
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: %v\n", err)
return ""
}
appRaw, _ := data["app"].(map[string]interface{})
if appRaw == nil {
return "", appsSubprocessEnvelopeError("query app type: response missing app object")
fmt.Fprintf(rctx.IO().ErrOut, "→ Could not query app type: response missing app object\n")
return ""
}
appType, _ := appRaw["app_type"].(string)
if strings.TrimSpace(appType) == "" {
return "", appsSubprocessEnvelopeError("query app type: response missing app_type")
}
return strings.ToLower(appType), nil
return strings.ToLower(appType)
}

View File

@@ -43,10 +43,7 @@ func TestQueryAppType_Success(t *testing.T) {
},
})
result, err := queryAppType(context.Background(), rt, "app_test")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := queryAppType(context.Background(), rt, "app_test")
if result != "modern_html" {
t.Errorf("queryAppType = %q, want modern_html", result)
}
@@ -68,10 +65,7 @@ func TestQueryAppType_FullStack(t *testing.T) {
},
})
result, err := queryAppType(context.Background(), rt, "app_fs")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := queryAppType(context.Background(), rt, "app_fs")
if result != "full_stack" {
t.Errorf("queryAppType = %q, want full_stack", result)
}
@@ -93,10 +87,7 @@ func TestQueryAppType_Html(t *testing.T) {
},
})
result, err := queryAppType(context.Background(), rt, "app_html")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
result := queryAppType(context.Background(), rt, "app_html")
if result != "html" {
t.Errorf("queryAppType = %q, want html", result)
}
@@ -111,9 +102,9 @@ func TestQueryAppType_APIError(t *testing.T) {
Body: map[string]interface{}{"code": float64(99999), "msg": "internal error"},
})
_, err := queryAppType(context.Background(), rt, "app_bad")
if err == nil {
t.Error("expected error on API failure")
result := queryAppType(context.Background(), rt, "app_bad")
if result != "" {
t.Errorf("queryAppType = %q, want empty on error", result)
}
}
@@ -128,9 +119,9 @@ func TestQueryAppType_MissingAppObject(t *testing.T) {
},
})
_, err := queryAppType(context.Background(), rt, "app_no")
if err == nil {
t.Error("expected error when app object missing")
result := queryAppType(context.Background(), rt, "app_no")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app object missing", result)
}
}
@@ -150,8 +141,8 @@ func TestQueryAppType_EmptyAppType(t *testing.T) {
},
})
_, err := queryAppType(context.Background(), rt, "app_empty")
if err == nil {
t.Error("expected error when app_type is empty")
result := queryAppType(context.Background(), rt, "app_empty")
if result != "" {
t.Errorf("queryAppType = %q, want empty when app_type is empty", result)
}
}

View File

@@ -31,13 +31,9 @@ var AppsReleaseCreate = common.Shortcut{
{Name: "branch", Desc: "release branch (server uses default if omitted)"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -30,13 +30,9 @@ var AppsReleaseGet = common.Shortcut{
{Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID := strings.TrimSpace(rctx.Str("app-id"))
if appID == "" {
if strings.TrimSpace(rctx.Str("app-id")) == "" {
return appsValidationParamError("--app-id", "--app-id is required")
}
if err := validateRealAppID(appID); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("release-id")) == "" {
return appsValidationParamError("--release-id", "--release-id is required")
}

View File

@@ -41,21 +41,6 @@ func withAppsHint(err error, hint string) error {
return err
}
// validateRealAppID checks that --app-id is a real app ID (app_ prefix).
// meta_token values are rejected with a hint to resolve via +get first.
func validateRealAppID(appID string) error {
if !strings.HasPrefix(appID, "app_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
`--app-id must be an app_id starting with "app_".`,
).WithParam("--app-id").WithHint(
`If you have a meta_token or a /page/<token>/ link, first resolve it:
lark-cli apps +get --app-id <meta_token> -q '.data.app.app_id'
Then retry this command with the returned app_id.`,
)
}
return nil
}
// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied
// --output path. The authoritative guard is the local FileIO layer
// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and

View File

@@ -75,7 +75,6 @@ var AppsGitCredentialInit = common.Shortcut{
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
}).
Params(gitCredentialIssueParams(appID))
},
@@ -91,12 +90,6 @@ var AppsGitCredentialInit = common.Shortcut{
"repository_url": result.GitHTTPURL,
"status": initStatus(result),
}
if result.CommitAuthorName != "" {
payload["commit_author_name"] = result.CommitAuthorName
}
if result.CommitAuthorEmail != "" {
payload["commit_author_email"] = result.CommitAuthorEmail
}
if result.ConfigWarning != "" {
payload["git_config_warning"] = result.ConfigWarning
}
@@ -468,13 +461,11 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC
}
}
issued := &gitcred.IssuedCredential{
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
CommitAuthorName: firstString(source, "commit_author_name"),
CommitAuthorEmail: firstString(source, "commit_author_email"),
AppID: firstString(source, "app_id", appID),
GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"),
Username: firstString(source, "username"),
PAT: firstString(source, "token", "Token", "pat", "password"),
ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"),
}
if issued.AppID == "" {
issued.AppID = appID

View File

@@ -87,7 +87,6 @@ func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) {
"save the issued PAT in the local system credential store",
"write app-scoped git credential metadata",
"configure a URL-scoped Git credential helper in global git config when possible",
"return commit_author_name and commit_author_email for repo-local git identity",
})
}

View File

@@ -129,13 +129,7 @@ func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string
if previous != nil && previous.PATRef != "" && previous.PATRef != ref {
_ = m.Secrets.Remove(previous.PATRef)
}
result := &InitResult{
AppID: appID,
GitHTTPURL: url,
Refreshed: previous != nil,
CommitAuthorName: issued.CommitAuthorName,
CommitAuthorEmail: issued.CommitAuthorEmail,
}
result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil}
if m.GitConfig != nil {
if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil {
result.ConfigWarning = err.Error()

View File

@@ -51,22 +51,18 @@ type CredentialRecord struct {
}
type IssuedCredential struct {
AppID string
GitHTTPURL string
Username string
PAT string
ExpiresAt int64
CommitAuthorName string
CommitAuthorEmail string
AppID string
GitHTTPURL string
Username string
PAT string
ExpiresAt int64
}
type InitResult struct {
AppID string
GitHTTPURL string
Refreshed bool
ConfigWarning string
CommitAuthorName string
CommitAuthorEmail string
AppID string
GitHTTPURL string
Refreshed bool
ConfigWarning string
}
type RemoveResult struct {

View File

@@ -0,0 +1,73 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"fmt"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type htmlPublishResponse struct {
URL string
}
type appsHTMLPublishClient interface {
HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error)
}
type appsHTMLPublishAPI struct {
runtime *common.RuntimeContext
}
func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tarball *htmlPublishTarball) (*htmlPublishResponse, error) {
fd := larkcore.NewFormdata()
fd.AddFile("file", bytes.NewReader(tarball.Body))
apiResp, err := api.runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("%s/apps/%s/upload_and_release_html_code", apiBasePath, validate.EncodePathSegment(appID)),
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return nil, client.WrapDoAPIError(err)
}
data, err := api.runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return nil, enrichHTMLPublishAPIError(err)
}
url, _ := data["url"].(string)
if url == "" {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse,
"html-publish response is missing the published app url")
}
return &htmlPublishResponse{URL: url}, nil
}
// OAPI business error codes returned by the
// /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend
// service; update when new codes are documented in the OAPI spec.
const (
errCodeBuildFailed = 90001 // tar.gz uploaded but server-side build failed
errCodeAppNotFound = 90002 // app_id unknown or caller lacks permission
)
func buildHTMLPublishFailureHint(code int) string {
switch code {
case errCodeBuildFailed:
return "server-side build failed: run `lark-cli apps +html-publish --app-id <your-app-id> --path <path> --dry-run` to inspect the packaged file list"
case errCodeAppNotFound:
return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)"
default:
return ""
}
}

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"mime"
"mime/multipart"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, _, _, reg := cmdutil.TestFactory(t, cfg)
rctx := common.TestNewRuntimeContextForAPI(context.Background(), nil, cfg, factory, core.AsUser)
return rctx, reg
}
func TestAppsHTMLPublishAPI_Success(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"url": "https://miaoda.feishu.cn/app/app_x",
},
},
}
reg.Register(stub)
api := appsHTMLPublishAPI{runtime: rctx}
tarball := &htmlPublishTarball{Body: []byte("fake"), Size: 4, SHA256: "abc"}
resp, err := api.HTMLPublish(context.Background(), "app_x", tarball)
if err != nil {
t.Fatalf("err=%v", err)
}
if resp.URL != "https://miaoda.feishu.cn/app/app_x" {
t.Fatalf("url=%q", resp.URL)
}
ct := stub.CapturedHeaders.Get("Content-Type")
mt, params, err := mime.ParseMediaType(ct)
if err != nil || mt != "multipart/form-data" {
t.Fatalf("content type %q wrong", ct)
}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
saw := false
for {
p, err := mr.NextPart()
if err != nil {
break
}
if p.FormName() == "file" {
saw = true
}
}
if !saw {
t.Fatalf("multipart missing 'file' part")
}
}
func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 90001,
"msg": "build failed: dependency conflict",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
if err == nil {
t.Fatalf("expected error")
}
problem := requireAppsAPIProblem(t, err)
if problem.Code != errCodeBuildFailed {
t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed)
}
if problem.Hint == "" {
t.Fatalf("expected non-empty hint on code 90001")
}
if !strings.Contains(problem.Message, "build failed") {
t.Fatalf("missing failure message: %v", problem.Message)
}
}
func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code",
Body: map[string]interface{}{
"code": errCodeAppNotFound,
"msg": "app not found",
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsAPIProblem(t, err)
if problem.Subtype != errs.SubtypeNotFound {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound)
}
if problem.Hint == "" {
t.Fatalf("expected app-not-found recovery hint")
}
}
func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}
func TestBuildHTMLPublishFailureHint_UnknownCodeReturnsEmpty(t *testing.T) {
// 默认分支:未识别的 code 返回空 hint让 Agent 用 message 兜底。
if hint := buildHTMLPublishFailureHint(99999); hint != "" {
t.Fatalf("unknown code should return empty hint, got %q", hint)
}
if hint := buildHTMLPublishFailureHint(0); hint != "" {
t.Fatalf("zero code should return empty hint, got %q", hint)
}
}
func TestBuildHTMLPublishFailureHint_KnownCodes(t *testing.T) {
if hint := buildHTMLPublishFailureHint(90001); hint == "" {
t.Fatalf("code 90001 should return non-empty hint")
}
if hint := buildHTMLPublishFailureHint(90002); hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
}
func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing.T) {
hint := buildHTMLPublishFailureHint(90002)
if hint == "" {
t.Fatalf("code 90002 should return non-empty hint")
}
if strings.Contains(hint, "+list") {
t.Fatalf("hint must not point at hidden +list command, got: %q", hint)
}
if !strings.Contains(hint, "app_id") {
t.Fatalf("hint should reference app_id, got: %q", hint)
}
}
func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) {
rctx, reg := newAppsClientRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code",
RawBody: []byte("{not json"),
})
api := appsHTMLPublishAPI{runtime: rctx}
_, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")})
problem := requireAppsProblem(t, err, errs.CategoryInternal)
if problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -122,7 +122,7 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) {
if grant["user_open_id"] != "ou_testuser" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
}
if grant["message"] != "Granted the current CLI user full_access on the new base." {
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new base." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -469,6 +469,9 @@ func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testi
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
@@ -574,9 +577,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
if got := stdout.String(); !strings.Contains(got, wantDesc) {
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
@@ -585,9 +587,8 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
if got := stdout.String(); !strings.Contains(got, wantDesc) {
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
@@ -596,7 +597,7 @@ func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})

View File

@@ -29,7 +29,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
Body(buildBaseCopyBody(runtime)).
Set("base_token", runtime.Str("base-token"))
if runtime.IsBot() {
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
d.Desc("After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
}
return d
}
@@ -37,7 +37,7 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D
func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI()
if runtime.IsBot() {
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base.")
d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.")
}
d.
POST("/open-apis/base/v3/bases").

View File

@@ -1,790 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +update room-availability pre-check helpers.
//
// Uses /open-apis/calendar/v4/freebusy/room_availability_check to warn the
// caller before an update either adds a new room attendee or shifts the time
// of a slot that already has a room reservation. --skip-room-check bypasses
// the check for callers that want to move fast.
package calendar
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
flagSkipRoomCheck = "skip-room-check"
roomCheckPath = "/open-apis/calendar/v4/freebusy/room_availability_check"
)
// roomAvailability mirrors a single room result from the API.
type roomAvailability struct {
RoomID string `json:"room_id,omitempty"`
RoomName string `json:"room_name,omitempty"`
Status string `json:"status,omitempty"`
UnavailableReasonType string `json:"unavailable_reason_type,omitempty"`
Strategy *roomStrategy `json:"room_strategy,omitempty"`
Requisition *roomRequisition `json:"room_requisition,omitempty"`
ApprovalInfo *roomApprovalInfo `json:"room_approval_info,omitempty"`
}
// roomStrategy mirrors the room_strategy block returned by the API on
// unavailable rooms. Every field is optional: the server only fills in the
// entries relevant to the current unavailable_reason_type.
type roomStrategy struct {
SingleMaxDuration string `json:"single_max_duration,omitempty"`
MaxAdvanceBookingTime string `json:"max_advance_booking_time,omitempty"`
DailyStartTime string `json:"daily_start_time,omitempty"`
DailyEndTime string `json:"daily_end_time,omitempty"`
Timezone string `json:"timezone,omitempty"`
DailyAdvanceWindowReleaseTime string `json:"daily_advance_window_release_time,omitempty"`
}
// roomRequisition mirrors room_requisition, returned by the API only when
// unavailable_reason_type == "during_requisition". Both fields are RFC3339
// strings and either may be empty if the server has no exact bound.
type roomRequisition struct {
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
}
// roomApprovalInfo mirrors room_approval_info, returned when the room requires
// (or may require) an approval submission before it can be booked.
//
// - ApprovalMode: "none" (no approval), "over_duration" (only when the
// booking exceeds the threshold), or "all" (every booking needs approval).
// - ApprovalDurationThreshold: seconds; only meaningful when
// ApprovalMode == "over_duration". The server returns it as a numeric
// string, matching the shape of the other duration fields.
//
// When the pre-check returns status == "need_approval" the caller renders a
// friendly reminder derived from these two fields plus the current event
// duration, so the agent knows whether to switch rooms/times or route the
// user through an approval flow.
type roomApprovalInfo struct {
ApprovalMode string `json:"approval_mode,omitempty"`
ApprovalDurationThreshold string `json:"approval_duration_threshold,omitempty"`
}
// eventSnapshot carries only the fields room-check needs from the current
// event: existing room IDs, current start/end (unix seconds string), timezone,
// and rrule.
type eventSnapshot struct {
RoomIDs []string
StartTs string
EndTs string
Timezone string
Recurrent string
}
// unavailableReasonHint maps API-declared unavailable reasons to a short
// English phrase suitable for embedding in the block message. Unknown or
// future reasons fall back to a single stable phrase so the CLI's blocked
// message stays predictable for agents that parse it.
func unavailableReasonHint(reason string) string {
switch reason {
case "reserved_by_other_event":
return "already reserved by another event"
case "past_time":
return "cannot book a room in the past"
case "beyond_advance_booking_window":
return "beyond the room's advance-booking window"
case "over_max_duration":
return "exceeds the room's max single-booking duration"
case "not_in_usable_time":
return "outside the room's daily bookable window"
case "during_requisition":
return "the room is disabled during this time and cannot be booked"
case "before_daily_advance_window_release":
return "the target date is outside the room's currently unlocked advance-booking window; the window extends by one calendar day at the daily release time"
case "recurring_exceed_approval_limit":
return "recurring event duration exceeds the limit for booking this approval-required room — shorten the duration or pick a different room"
default:
return "currently unbookable"
}
}
// strategyDetail renders the human-readable suffix appended to the reason
// phrase for a given (reason, strategy) pair. It returns an empty string when
// no strategy data is available or when the fields relevant to this reason
// are missing / invalid, so callers can safely concatenate the result.
func strategyDetail(reason string, s *roomStrategy) string {
if s == nil {
return ""
}
switch reason {
case "over_max_duration":
if d := formatDurationSeconds(s.SingleMaxDuration); d != "" {
return "the max single-booking duration is " + d
}
case "beyond_advance_booking_window":
// The API returns max_advance_booking_time as RFC3339 already;
// surface it verbatim so agents don't lose the exact instant.
if t := strings.TrimSpace(s.MaxAdvanceBookingTime); t != "" {
return "the latest bookable end time is " + t
}
case "not_in_usable_time":
start := formatDaySeconds(s.DailyStartTime)
end := formatDaySeconds(s.DailyEndTime)
zone := roomZoneLabel(s.Timezone)
switch {
case start != "" && end != "":
return fmt.Sprintf("the daily bookable window is %s - %s (%s)", start, end, zone)
case start != "":
return fmt.Sprintf("the daily bookable window starts at %s (%s)", start, zone)
case end != "":
return fmt.Sprintf("the daily bookable window ends at %s (%s)", end, zone)
}
case "before_daily_advance_window_release":
if t := formatDaySeconds(s.DailyAdvanceWindowReleaseTime); t != "" {
return fmt.Sprintf("the next unlock happens today at %s (%s), which advances the window by one day", t, roomZoneLabel(s.Timezone))
}
}
return ""
}
// requisitionDetail renders the suffix describing the room's scheduled
// disable window for a `during_requisition` block. The API sends both bounds
// as RFC3339 already, so we surface them verbatim to keep the exact instant.
// Returns "" when both bounds are missing so the caller falls back to the
// generic "pick a different time or a different room" recovery hint.
func requisitionDetail(reason string, r *roomRequisition) string {
if reason != "during_requisition" || r == nil {
return ""
}
start := strings.TrimSpace(r.StartTime)
end := strings.TrimSpace(r.EndTime)
switch {
case start != "" && end != "":
return fmt.Sprintf("the disabled period is %s to %s", start, end)
case start != "":
return "the disabled period starts at " + start
case end != "":
return "the disabled period ends at " + end
}
return ""
}
// formatDurationSeconds renders a whole-second string like "10800" as a
// compact "H hours [M minutes]" phrase. Returns "" when the value is
// missing, non-numeric, or non-positive.
func formatDurationSeconds(raw string) string {
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || sec <= 0 {
return ""
}
d := time.Duration(sec) * time.Second
h := int(d / time.Hour)
m := int((d % time.Hour) / time.Minute)
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%d hours %d minutes", h, m)
case h > 0:
return fmt.Sprintf("%d hours", h)
case m > 0:
return fmt.Sprintf("%d minutes", m)
default:
return fmt.Sprintf("%d seconds", sec)
}
}
// formatDaySeconds renders a "seconds since midnight" string as "HH:MM".
// Returns "" when raw is missing, non-numeric, or outside [0, 24h). Seconds
// are truncated because the API only guarantees minute-level meaning for
// daily windows and release times.
func formatDaySeconds(raw string) string {
sec, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || sec < 0 || sec >= 24*3600 {
return ""
}
h := sec / 3600
m := (sec % 3600) / 60
return fmt.Sprintf("%02d:%02d", h, m)
}
// roomZoneLabel renders the room's timezone as either a "GMT±X" string
// anchored to today (so DST is respected) when the IANA name resolves, or
// the IANA name itself as a fallback so agents always see the source of
// truth. Returns the local device timezone's label when raw is empty.
func roomZoneLabel(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return gmtOffsetLabel(time.Now())
}
loc, err := time.LoadLocation(raw)
if err != nil {
return raw
}
return gmtOffsetLabel(time.Now().In(loc))
}
// gmtOffsetLabel formats t's zone offset as "GMT+8" / "GMT-5:30" / "GMT".
// Minute-precision is included only when the offset has a non-zero minute
// component so the common whole-hour case stays terse.
func gmtOffsetLabel(t time.Time) string {
_, offsetSec := t.Zone()
if offsetSec == 0 {
return "GMT"
}
sign := "+"
if offsetSec < 0 {
sign = "-"
offsetSec = -offsetSec
}
h := offsetSec / 3600
m := (offsetSec % 3600) / 60
if m == 0 {
return fmt.Sprintf("GMT%s%d", sign, h)
}
return fmt.Sprintf("GMT%s%d:%02d", sign, h, m)
}
// collectAttendeeRoomIDs extracts omm_ prefixed IDs from a comma-separated
// flag value. Empty / whitespace input returns nil.
func collectAttendeeRoomIDs(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var rooms []string
seen := map[string]struct{}{}
for _, part := range strings.Split(raw, ",") {
id := strings.TrimSpace(part)
if !strings.HasPrefix(id, "omm_") {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
rooms = append(rooms, id)
}
return rooms
}
// fetchEventSnapshot GETs the event with attendees so we can read the current
// start / end / recurrence and the room IDs already booked on the event. It is
// best-effort: any error bubbles up so the caller can降级放行 by warning.
//
// One retry is baked in: a `{uid}_{original_time}` event_id refers to a
// specific instance of a recurring series, but until that instance is edited
// and materialised as an exception, the server only knows the master
// (`{uid}_0`) and answers 193001 (event not found). We detect that shape and
// re-issue the GET against the master so the room-check pipeline still has a
// snapshot to work with.
func fetchEventSnapshot(_ context.Context, runtime *common.RuntimeContext, calendarID, eventID string) (*eventSnapshot, error) {
data, err := callEventGet(runtime, calendarID, eventID)
if err != nil {
if masterID, ok := recurringMasterEventID(eventID); ok && isEventNotFound(err) {
data, err = callEventGet(runtime, calendarID, masterID)
}
if err != nil {
return nil, err
}
}
event, _ := data["event"].(map[string]interface{})
if event == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
}
snap := &eventSnapshot{}
if start, _ := event["start_time"].(map[string]interface{}); start != nil {
if ts, _ := start["timestamp"].(string); ts != "" {
snap.StartTs = ts
}
if tz, _ := start["timezone"].(string); tz != "" {
snap.Timezone = tz
}
}
if end, _ := event["end_time"].(map[string]interface{}); end != nil {
if ts, _ := end["timestamp"].(string); ts != "" {
snap.EndTs = ts
}
if snap.Timezone == "" {
if tz, _ := end["timezone"].(string); tz != "" {
snap.Timezone = tz
}
}
}
if r, _ := event["recurrence"].(string); r != "" {
snap.Recurrent = r
}
attendees, _ := event["attendees"].([]interface{})
seen := map[string]struct{}{}
for _, raw := range attendees {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
if t, _ := m["type"].(string); t != "resource" {
continue
}
id, _ := m["room_id"].(string)
if id == "" {
continue
}
if status, _ := m["rsvp_status"].(string); status == "removed" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
snap.RoomIDs = append(snap.RoomIDs, id)
}
return snap, nil
}
// callEventGet issues the calendar event GET used by fetchEventSnapshot. It
// is factored out so the 193001 fallback can re-issue the request against
// the master event without duplicating the params / path plumbing.
func callEventGet(runtime *common.RuntimeContext, calendarID, eventID string) (map[string]interface{}, error) {
path := fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
validate.EncodePathSegment(calendarID), validate.EncodePathSegment(eventID))
params := map[string]interface{}{
"user_id_type": "open_id",
"need_attendee": true,
"max_attendee_num": 20,
}
return runtime.CallAPITyped("GET", path, params, nil)
}
// recurringMasterEventID inspects a calendar event_id shaped like
// `{uid}_{original_time}` and returns `{uid}_0` when original_time is a
// positive integer, plus true so callers know a fallback is worth trying.
// Any other shape (missing underscore, non-numeric suffix, already `_0`, or
// suffix `0` / negative) returns "", false so we don't retry pointlessly.
func recurringMasterEventID(eventID string) (string, bool) {
idx := strings.LastIndex(eventID, "_")
if idx <= 0 || idx == len(eventID)-1 {
return "", false
}
uid := eventID[:idx]
suffix := eventID[idx+1:]
n, err := strconv.ParseInt(suffix, 10, 64)
if err != nil || n <= 0 {
return "", false
}
return uid + "_0", true
}
// isEventNotFound returns true when err is a calendar 193001 (event not
// found) API error. Kept in this file rather than shared with
// unwrapCalendarAPIError because that helper returns a user-facing hint —
// here we only need the classification, not the copy.
func isEventNotFound(err error) bool {
if err == nil {
return false
}
var ae *errs.APIError
if !errors.As(err, &ae) {
return false
}
return ae.Code == 193001
}
// roomCheckPlan bundles the resolved inputs for the pre-check API call.
type roomCheckPlan struct {
RoomIDs []string
StartTs string
EndTs string
StartTimezone string
Rrule string
}
// resolveRoomCheckPlan works out which rooms to check and the target time
// window. It applies the降级放行 policy: if the event snapshot fails to load
// but we can proceed with only user-provided inputs (i.e., time changed and a
// new room is added), the pre-check still runs against those. Otherwise it
// warns and returns (nil, nil) so the caller skips the check.
//
// Returns (nil, nil) when no check is warranted.
func resolveRoomCheckPlan(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, newStartTs, newEndTs string, timeChanged, rruleChanged bool) (*roomCheckPlan, error) {
newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
removeSet := map[string]struct{}{}
for _, id := range collectAttendeeRoomIDs(runtime.Str("remove-attendee-ids")) {
removeSet[id] = struct{}{}
}
// Fast path: only trigger the check when it can find something to look at.
// - New room attendees → always check.
// - Time or rrule change → check existing rooms if any.
if len(newRooms) == 0 && !timeChanged && !rruleChanged {
return nil, nil
}
newRrule := strings.TrimSpace(runtime.Str("rrule"))
// If we don't need existing rooms and have both start/end, skip the GET.
needSnapshot := timeChanged || rruleChanged || !timeChanged && len(newRooms) > 0
var snap *eventSnapshot
if needSnapshot {
var err error
snap, err = fetchEventSnapshot(ctx, runtime, calendarID, eventID)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +update] warning: failed to fetch current event for room-availability check (%v); precheck runs only against user-supplied inputs — pass --%s to silence\n",
err, flagSkipRoomCheck)
snap = nil
}
}
plan := &roomCheckPlan{
StartTs: newStartTs,
EndTs: newEndTs,
Rrule: newRrule,
}
if plan.StartTs == "" && snap != nil {
plan.StartTs = snap.StartTs
}
if plan.EndTs == "" && snap != nil {
plan.EndTs = snap.EndTs
}
if plan.Rrule == "" && snap != nil {
plan.Rrule = snap.Recurrent
}
if snap != nil {
plan.StartTimezone = snap.Timezone
}
seen := map[string]struct{}{}
addRoom := func(id string) {
if id == "" {
return
}
if _, ok := removeSet[id]; ok {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
plan.RoomIDs = append(plan.RoomIDs, id)
}
for _, id := range newRooms {
addRoom(id)
}
if snap != nil && (timeChanged || rruleChanged) {
for _, id := range snap.RoomIDs {
addRoom(id)
}
}
if len(plan.RoomIDs) == 0 {
return nil, nil
}
// Without a target window the server has no basis to check anything;
// prefer degrading gracefully to blocking legitimate updates.
if plan.StartTs == "" || plan.EndTs == "" {
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +update] warning: room-availability check skipped because start/end could not be resolved; pass --%s to silence\n",
flagSkipRoomCheck)
return nil, nil
}
return plan, nil
}
// roomCheckPlanDurationSec returns the current booking duration in whole
// seconds derived from the resolved plan's Unix-second window, or 0 when
// either bound is missing or unparseable. Used to compare against
// approval_duration_threshold when the API asks for approval.
func roomCheckPlanDurationSec(plan *roomCheckPlan) int64 {
if plan == nil {
return 0
}
start, err := strconv.ParseInt(strings.TrimSpace(plan.StartTs), 10, 64)
if err != nil {
return 0
}
end, err := strconv.ParseInt(strings.TrimSpace(plan.EndTs), 10, 64)
if err != nil {
return 0
}
if end <= start {
return 0
}
return end - start
}
// buildRoomCheckBody assembles the request body for room_availability_check.
// The pre-check API expects start/end as RFC3339 timestamps; we take the
// Unix-second strings used elsewhere in the update flow and render them in
// the event's own timezone when available, falling back to the local device
// timezone so agents on different machines still produce a valid request.
// start_timezone is an IANA name (e.g. "Asia/Shanghai") copied from the event
// snapshot; it is omitted when unknown so the server can fall back to its own
// default.
func buildRoomCheckBody(calendarID, eventID string, plan *roomCheckPlan) map[string]interface{} {
loc := time.Local
if plan.StartTimezone != "" {
if l, err := time.LoadLocation(plan.StartTimezone); err == nil {
loc = l
}
}
body := map[string]interface{}{
"calendar_id": calendarID,
"event_id": eventID,
"start_time": formatRoomCheckTime(plan.StartTs, loc),
"end_time": formatRoomCheckTime(plan.EndTs, loc),
"room_ids": plan.RoomIDs,
}
if plan.StartTimezone != "" {
body["start_timezone"] = plan.StartTimezone
}
if plan.Rrule != "" {
body["event_rrule"] = plan.Rrule
}
return body
}
// formatRoomCheckTime renders a Unix-second string as RFC3339 in loc.
// Non-numeric input is returned unchanged so anomalies stay visible instead
// of being silently rewritten to the epoch.
func formatRoomCheckTime(unixStr string, loc *time.Location) string {
sec, err := strconv.ParseInt(strings.TrimSpace(unixStr), 10, 64)
if err != nil {
return unixStr
}
return time.Unix(sec, 0).In(loc).Format(time.RFC3339)
}
// callRoomAvailabilityCheck posts the availability request and returns per-room
// results.
func callRoomAvailabilityCheck(runtime *common.RuntimeContext, body map[string]interface{}) ([]roomAvailability, error) {
data, err := runtime.CallAPITyped("POST", roomCheckPath, nil, body)
if err != nil {
return nil, err
}
rawList, _ := data["room_availabilitys"].([]interface{})
out := make([]roomAvailability, 0, len(rawList))
for _, raw := range rawList {
m, ok := raw.(map[string]interface{})
if !ok {
continue
}
item := roomAvailability{}
if v, ok := m["room_id"].(string); ok {
item.RoomID = v
}
if v, ok := m["room_name"].(string); ok {
item.RoomName = v
}
if v, ok := m["status"].(string); ok {
item.Status = v
}
if v, ok := m["unavailable_reason_type"].(string); ok {
item.UnavailableReasonType = v
}
if strat, ok := m["room_strategy"].(map[string]interface{}); ok {
item.Strategy = parseRoomStrategy(strat)
}
if req, ok := m["room_requisition"].(map[string]interface{}); ok {
item.Requisition = parseRoomRequisition(req)
}
if info, ok := m["room_approval_info"].(map[string]interface{}); ok {
item.ApprovalInfo = parseRoomApprovalInfo(info)
}
out = append(out, item)
}
return out, nil
}
// parseRoomStrategy extracts the optional strategy fields from a raw API
// map. Missing / non-string values are dropped so callers only see what the
// server actually sent.
func parseRoomStrategy(m map[string]interface{}) *roomStrategy {
s := &roomStrategy{}
if v, ok := m["single_max_duration"].(string); ok {
s.SingleMaxDuration = v
}
if v, ok := m["max_advance_booking_time"].(string); ok {
s.MaxAdvanceBookingTime = v
}
if v, ok := m["daily_start_time"].(string); ok {
s.DailyStartTime = v
}
if v, ok := m["daily_end_time"].(string); ok {
s.DailyEndTime = v
}
if v, ok := m["timezone"].(string); ok {
s.Timezone = v
}
if v, ok := m["daily_advance_window_release_time"].(string); ok {
s.DailyAdvanceWindowReleaseTime = v
}
return s
}
// parseRoomRequisition extracts the optional room_requisition block from a
// raw API map. Missing / non-string values are dropped.
func parseRoomRequisition(m map[string]interface{}) *roomRequisition {
r := &roomRequisition{}
if v, ok := m["start_time"].(string); ok {
r.StartTime = v
}
if v, ok := m["end_time"].(string); ok {
r.EndTime = v
}
return r
}
// parseRoomApprovalInfo extracts the optional room_approval_info block from a
// raw API map. Missing / non-string values are dropped.
func parseRoomApprovalInfo(m map[string]interface{}) *roomApprovalInfo {
info := &roomApprovalInfo{}
if v, ok := m["approval_mode"].(string); ok {
info.ApprovalMode = v
}
if v, ok := m["approval_duration_threshold"].(string); ok {
info.ApprovalDurationThreshold = v
}
return info
}
// approvalReasonHint composes the per-line phrase for a `need_approval`
// status. The API returns `room_approval_info` with:
//
// - "all" → every reservation on this room must be approved.
// - "over_duration" → only bookings longer than approval_duration_threshold
// need approval. The current event duration (eventDurationSec) is compared
// against the threshold so agents can see exactly why approval is being
// asked for — and, when the current duration is below the threshold, the
// message points at the "shorten it" recovery path.
// - anything else → generic reminder so unknown modes still surface.
//
// This function only produces the per-room fragment. The shared recovery
// clause (attendees-create, client fallback, shorten, pick another room) is
// appended once by blockOnUnavailableRooms into `.WithHint(...)` so a message
// with several approval-required rooms doesn't repeat the same recovery
// paragraph on every line.
func approvalReasonHint(info *roomApprovalInfo, eventDurationSec int64) string {
mode := ""
if info != nil {
mode = strings.TrimSpace(info.ApprovalMode)
}
switch mode {
case "all":
return "this room requires approval for every reservation"
case "over_duration":
threshold, _ := strconv.ParseInt(strings.TrimSpace(info.ApprovalDurationThreshold), 10, 64)
if threshold <= 0 {
// Server said approval-by-duration but didn't give a threshold —
// keep the mode label so agents don't lose the classification.
return "this room requires approval when the booking exceeds a duration threshold"
}
thresholdPhrase := formatDurationSeconds(info.ApprovalDurationThreshold)
if thresholdPhrase == "" {
thresholdPhrase = fmt.Sprintf("%d seconds", threshold)
}
base := fmt.Sprintf("this room requires approval when the booking exceeds %s", thresholdPhrase)
if eventDurationSec > 0 {
currentPhrase := formatDurationSeconds(strconv.FormatInt(eventDurationSec, 10))
if currentPhrase == "" {
currentPhrase = fmt.Sprintf("%d seconds", eventDurationSec)
}
if eventDurationSec >= threshold {
base += fmt.Sprintf(" (current duration is %s)", currentPhrase)
} else {
// Server flagged approval but our duration reads as below the
// threshold — surface both so the agent can reconcile rather
// than guess.
base += fmt.Sprintf(" (current duration reads as %s; server still flagged approval)", currentPhrase)
}
}
return base
default:
return "this room requires approval before it can be booked"
}
}
// roomLabel renders the room identifier for the block message. When the API
// returns a human-readable name it becomes `<room_id>[<room_name>]`; a blank
// name (or an entirely blank id, defensive) degrades to whichever is present
// so agents can still address the room. The room_id is kept as the primary
// identifier because callers act on it programmatically. Square brackets are
// used (rather than parentheses) so a room name that itself contains
// parentheses — e.g. "Room A (west wing)" — doesn't produce ambiguous nesting
// like `omm_1(Room A (west wing))`.
func roomLabel(id, name string) string {
id = strings.TrimSpace(id)
name = strings.TrimSpace(name)
switch {
case id != "" && name != "":
return fmt.Sprintf("%s[%s]", id, name)
case id != "":
return id
default:
return name
}
}
// blockOnUnavailableRooms returns a typed validation error when any room in
// results is unavailable or requires approval, or nil when everything is
// bookable. The error text carries per-room reasons plus the retry command
// hint from the PRD. When the API returns a room_strategy for a blocked room,
// the relevant limit (max duration, latest bookable time, daily window, or
// daily release time) is appended after the reason so agents can relay it to
// the user without making a follow-up request. For a `during_requisition`
// block, the disabled period (from room_requisition) is appended if available;
// a "pick a different time or a different room" recovery clause is always
// appended so the message reads coherently whether or not exact bounds are
// known.
//
// `need_approval` results are treated as blocking (the CLI cannot submit an
// approval on the user's behalf, so silently PATCHing would surprise the
// user). The line uses room_approval_info + eventDurationSec to explain the
// mode ("all" / "over_duration"), the threshold, and — for over_duration —
// how the current booking compares. The shared "how do I actually recover
// from approval" clause is folded into the hint once (not per line), so
// several approval-required rooms don't repeat the same paragraph.
func blockOnUnavailableRooms(results []roomAvailability, eventDurationSec int64) error {
var blocked []roomAvailability
for _, r := range results {
if r.Status != "available" {
blocked = append(blocked, r)
}
}
if len(blocked) == 0 {
return nil
}
var lines []string
hasNeedApproval := false
for _, r := range blocked {
var reason string
switch r.Status {
case "need_approval":
hasNeedApproval = true
reason = approvalReasonHint(r.ApprovalInfo, eventDurationSec)
default:
reason = unavailableReasonHint(r.UnavailableReasonType)
}
line := fmt.Sprintf("%s: %s", roomLabel(r.RoomID, r.RoomName), reason)
if detail := strategyDetail(r.UnavailableReasonType, r.Strategy); detail != "" {
line += ", " + detail
}
if detail := requisitionDetail(r.UnavailableReasonType, r.Requisition); detail != "" {
line += ", " + detail
}
if r.UnavailableReasonType == "during_requisition" {
line += "; pick a different time or a different room"
}
lines = append(lines, line)
}
msg := "meeting room booking will fail after this event change:\n " + strings.Join(lines, "\n ")
hint := fmt.Sprintf("do NOT auto-retry: relay the room IDs and reasons above to the user and get explicit confirmation before re-running with --%s.",
flagSkipRoomCheck)
if hasNeedApproval {
hint += " Rooms flagged need_approval: the CLI cannot submit approvals; DO NOT auto-run any recovery — ask the user first, then pick one: (a) newly added room → after the user confirms and provides `approval_reason`, run `lark-cli calendar event.attendees create --as user`; (b) time/rrule change re-triggers approval on an existing room → ask the user to update through the client; (c) shorten the meeting below the threshold or pick a different room."
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", msg).WithHint("%s", hint)
}

View File

@@ -3368,952 +3368,3 @@ func TestGet_MissingEventField_TypedInternal(t *testing.T) {
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
}
}
// ---------------------------------------------------------------------------
// CalendarUpdate room-availability precheck tests
// ---------------------------------------------------------------------------
// eventSnapshotStub builds a GET-event fixture with the given rooms + window
// so room-check helpers can read a plausible snapshot.
func eventSnapshotStub(calendarID, eventID, startTs, endTs string, roomIDs ...string) *httpmock.Stub {
attendees := make([]interface{}, 0, len(roomIDs))
for _, id := range roomIDs {
attendees = append(attendees, map[string]interface{}{
"type": "resource",
"room_id": id,
})
}
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/" + calendarID + "/events/" + eventID,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": eventID,
"summary": "Existing",
"start_time": map[string]interface{}{"timestamp": startTs, "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": endTs, "timezone": "Asia/Shanghai"},
"attendees": attendees,
},
},
},
Reusable: true,
}
}
func TestUpdate_RoomCheck_SkipFlag_BypassesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Register the PATCH stub but no room-check stub — the test asserts that no
// unmatched request is made.
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc1",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc1"}},
},
}
reg.Register(patchStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc1",
"--calendar-id", "cal_rc",
"--summary", "Skip",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--skip-room-check",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patchStub.CapturedBody) == 0 {
t.Fatalf("expected PATCH to be captured")
}
}
func TestUpdate_RoomCheck_TitleOnly_SkipsCheck(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Only registered PATCH; title-only changes should never trigger room-check
// and never fetch the event snapshot.
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc2",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc2"}},
},
}
reg.Register(patchStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc2",
"--calendar-id", "cal_rc",
"--summary", "New title only",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(patchStub.CapturedBody) == 0 {
t.Fatalf("expected PATCH to be captured")
}
}
func TestUpdate_RoomCheck_NewRoomAvailable_Allows(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Snapshot has no existing rooms; we're adding omm_new.
reg.Register(eventSnapshotStub("cal_rc", "evt_rc3", "1742515200", "1742518800"))
checkStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{"room_id": "omm_new", "status": "available"},
},
},
},
}
reg.Register(checkStub)
addStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc3/attendees",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
reg.Register(addStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc3",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_new",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(checkStub.CapturedBody) == 0 {
t.Fatalf("expected room-availability-check to be called")
}
body := decodeCalendarCapturedBody(t, checkStub)
rooms, _ := body["room_ids"].([]interface{})
if len(rooms) != 1 || rooms[0] != "omm_new" {
t.Fatalf("room_ids should be [omm_new], got %#v", rooms)
}
if body["calendar_id"] != "cal_rc" || body["event_id"] != "evt_rc3" {
t.Fatalf("room-check body missing ids: %#v", body)
}
if body["start_timezone"] != "Asia/Shanghai" {
t.Fatalf("start_timezone should carry snapshot value, got %#v", body["start_timezone"])
}
if body["start_time"] != "2025-03-21T08:00:00+08:00" {
t.Fatalf("start_time should be RFC3339 in event tz, got %#v", body["start_time"])
}
if body["end_time"] != "2025-03-21T09:00:00+08:00" {
t.Fatalf("end_time should be RFC3339 in event tz, got %#v", body["end_time"])
}
if len(addStub.CapturedBody) == 0 {
t.Fatalf("expected add-attendees POST to run")
}
}
func TestUpdate_RoomCheck_NewRoomUnavailable_Blocks(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(eventSnapshotStub("cal_rc", "evt_rc4", "1742515200", "1742518800"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{
"room_id": "omm_busy",
"status": "unavailable",
"unavailable_reason_type": "reserved_by_other_event",
},
},
},
},
})
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc4",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_busy",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected block error when room is unavailable")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype=%q, want failed_precondition", ve.Subtype)
}
if !strings.Contains(ve.Message, "omm_busy") {
t.Errorf("message should list blocked room id, got: %q", ve.Message)
}
if !strings.Contains(ve.Hint, "--skip-room-check") {
t.Errorf("hint should mention --skip-room-check, got: %q", ve.Hint)
}
}
func TestUpdate_RoomCheck_TimeChanged_ChecksExistingRoom(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Existing event already has omm_existing booked.
reg.Register(eventSnapshotStub("cal_rc", "evt_rc5", "1742515200", "1742518800", "omm_existing"))
checkStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{"room_id": "omm_existing", "status": "available"},
},
},
},
}
reg.Register(checkStub)
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc5",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "evt_rc5"}},
},
}
reg.Register(patchStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc5",
"--calendar-id", "cal_rc",
"--start", "2025-03-21T02:00:00+08:00",
"--end", "2025-03-21T03:00:00+08:00",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(checkStub.CapturedBody) == 0 {
t.Fatalf("expected room-check to run for existing room on time change")
}
body := decodeCalendarCapturedBody(t, checkStub)
rooms, _ := body["room_ids"].([]interface{})
if len(rooms) != 1 || rooms[0] != "omm_existing" {
t.Fatalf("room_ids should be [omm_existing], got %#v", rooms)
}
if len(patchStub.CapturedBody) == 0 {
t.Fatalf("expected PATCH to run after check passes")
}
}
func TestUpdate_RoomCheck_APIFailure_DegradesGracefully(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(eventSnapshotStub("cal_rc", "evt_rc6", "1742515200", "1742518800"))
// Simulate room-check API failure (e.g., not yet rolled out) so the CLI
// degrades gracefully instead of blocking the update.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 190001,
"msg": "permission denied",
},
})
addStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/evt_rc6/attendees",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
}
reg.Register(addStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc6",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_new",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(addStub.CapturedBody) == 0 {
t.Fatalf("expected add-attendees POST to run despite check failure")
}
if !strings.Contains(stderr.String(), "room availability check failed") {
t.Errorf("stderr should warn about degraded check, got: %q", stderr.String())
}
}
func TestUpdate_RoomCheck_DryRun_IncludesPrecheckStep(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc7",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_dryrun",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "room_availability_check") {
t.Fatalf("dry-run should preview room_availability_check, got: %s", out)
}
if !strings.Contains(out, "Pre-check meeting room availability") {
t.Fatalf("dry-run should describe pre-check step, got: %s", out)
}
}
func TestUpdate_RoomCheck_DryRun_SkipFlagOmitsStep(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc8",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_dryrun2",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--skip-room-check",
"--dry-run",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if strings.Contains(out, "room_availability_check") {
t.Fatalf("dry-run with --skip-room-check should not preview room_availability_check, got: %s", out)
}
}
// TestStrategyDetail_ByReason exercises the human-readable strategy suffix
// appended to each blocked-room line. Timezone-anchored fields use a fixed
// IANA name so the offset ("GMT+8") is deterministic across machines.
func TestStrategyDetail_ByReason(t *testing.T) {
tests := []struct {
name string
reason string
strategy *roomStrategy
want string
}{
{
name: "over_max_duration renders as hours",
reason: "over_max_duration",
strategy: &roomStrategy{SingleMaxDuration: "10800"},
want: "the max single-booking duration is 3 hours",
},
{
name: "over_max_duration mixed hours and minutes",
reason: "over_max_duration",
strategy: &roomStrategy{SingleMaxDuration: "5400"},
want: "the max single-booking duration is 1 hours 30 minutes",
},
{
name: "beyond_advance_booking_window surfaces rfc3339 verbatim",
reason: "beyond_advance_booking_window",
strategy: &roomStrategy{MaxAdvanceBookingTime: "2026-07-13T18:00:00+08:00", Timezone: "Asia/Shanghai"},
want: "the latest bookable end time is 2026-07-13T18:00:00+08:00",
},
{
name: "not_in_usable_time renders day-seconds and zone",
reason: "not_in_usable_time",
strategy: &roomStrategy{DailyStartTime: "36000", DailyEndTime: "72000", Timezone: "Asia/Shanghai"},
want: "the daily bookable window is 10:00 - 20:00 (GMT+8)",
},
{
name: "before_daily_advance_window_release renders unlock time and zone",
reason: "before_daily_advance_window_release",
strategy: &roomStrategy{DailyAdvanceWindowReleaseTime: "28800", Timezone: "Asia/Shanghai"},
want: "the next unlock happens today at 08:00 (GMT+8), which advances the window by one day",
},
{
name: "past_time has no strategy suffix",
reason: "past_time",
strategy: &roomStrategy{SingleMaxDuration: "10800"},
want: "",
},
{
name: "nil strategy returns empty",
reason: "over_max_duration",
strategy: nil,
want: "",
},
{
name: "invalid duration returns empty",
reason: "over_max_duration",
strategy: &roomStrategy{SingleMaxDuration: "not-a-number"},
want: "",
},
{
name: "day-seconds out of range returns empty",
reason: "not_in_usable_time",
strategy: &roomStrategy{DailyStartTime: "-1", DailyEndTime: "999999", Timezone: "Asia/Shanghai"},
want: "",
},
{
name: "unresolvable timezone falls back to iana name",
reason: "before_daily_advance_window_release",
strategy: &roomStrategy{DailyAdvanceWindowReleaseTime: "28800", Timezone: "Not/AReal_Zone"},
want: "the next unlock happens today at 08:00 (Not/AReal_Zone), which advances the window by one day",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strategyDetail(tt.reason, tt.strategy)
if got != tt.want {
t.Errorf("strategyDetail(%q) = %q, want %q", tt.reason, got, tt.want)
}
})
}
}
// TestUpdate_RoomCheck_StrategyDetailInMessage pins that when the API returns a
// room_strategy alongside the unavailable_reason_type, blockOnUnavailableRooms
// surfaces the specific limit inline so agents can relay it to the user
// without an extra round trip.
func TestUpdate_RoomCheck_StrategyDetailInMessage(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_strategy", "1742515200", "1742525200"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{
"room_id": "omm_toolong",
"status": "unavailable",
"unavailable_reason_type": "over_max_duration",
"room_strategy": map[string]interface{}{
"single_max_duration": "10800",
"timezone": "Asia/Shanghai",
},
},
},
},
},
})
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc_strategy",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_toolong",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected block error when strategy limit is hit")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
}
if !strings.Contains(ve.Message, "the max single-booking duration is 3 hours") {
t.Errorf("message should surface the max-duration limit, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "omm_toolong") {
t.Errorf("message should still list the room id, got: %q", ve.Message)
}
}
// TestRequisitionDetail_ByBounds pins the human-readable suffix rendered for a
// `during_requisition` block. Every variant (both bounds, start only, end
// only, none, nil requisition, non-matching reason) must degrade coherently.
func TestRequisitionDetail_ByBounds(t *testing.T) {
tests := []struct {
name string
req *roomRequisition
want string
}{
{
name: "both bounds surface as verbatim rfc3339 range",
req: &roomRequisition{StartTime: "2026-07-13T09:00:00+08:00", EndTime: "2026-07-13T18:00:00+08:00"},
want: "the disabled period is 2026-07-13T09:00:00+08:00 to 2026-07-13T18:00:00+08:00",
},
{
name: "start only",
req: &roomRequisition{StartTime: "2026-07-13T09:00:00+08:00"},
want: "the disabled period starts at 2026-07-13T09:00:00+08:00",
},
{
name: "end only",
req: &roomRequisition{EndTime: "2026-07-13T18:00:00+08:00"},
want: "the disabled period ends at 2026-07-13T18:00:00+08:00",
},
{
name: "empty bounds return no detail",
req: &roomRequisition{},
want: "",
},
{
name: "nil requisition returns empty",
req: nil,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := requisitionDetail("during_requisition", tt.req)
if got != tt.want {
t.Errorf("requisitionDetail(during_requisition) = %q, want %q", got, tt.want)
}
})
}
// Non-matching reason should always short-circuit even with a full payload.
if got := requisitionDetail("reserved_by_other_event", &roomRequisition{StartTime: "x", EndTime: "y"}); got != "" {
t.Errorf("requisitionDetail should ignore requisition for non-during_requisition reasons, got %q", got)
}
}
// TestUpdate_RoomCheck_RequisitionDetailInMessage pins that when the API
// returns room_requisition alongside a during_requisition block, the disabled
// period is surfaced inline and the recovery clause is always present.
func TestUpdate_RoomCheck_RequisitionDetailInMessage(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_req", "1742515200", "1742525200"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{
"room_id": "omm_req",
"room_name": "Meeting Room A",
"status": "unavailable",
"unavailable_reason_type": "during_requisition",
"room_requisition": map[string]interface{}{
"start_time": "2026-07-13T09:00:00+08:00",
"end_time": "2026-07-13T18:00:00+08:00",
},
},
},
},
},
})
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc_req",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_req",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected block error for during_requisition")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
}
if !strings.Contains(ve.Message, "the disabled period is 2026-07-13T09:00:00+08:00 to 2026-07-13T18:00:00+08:00") {
t.Errorf("message should surface the disabled period, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "pick a different time or a different room") {
t.Errorf("message should always include recovery hint, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "omm_req[Meeting Room A]") {
t.Errorf("message should render room id with human-readable name, got: %q", ve.Message)
}
}
// TestRoomLabel_ByFields pins the room identifier rendering used in the block
// message. `<room_id>(<room_name>)` when both are present; degrades to
// whichever is non-empty when the other is missing.
func TestRoomLabel_ByFields(t *testing.T) {
tests := []struct {
name string
id string
room string
want string
}{
{name: "both present", id: "omm_1", room: "Meeting Room A", want: "omm_1[Meeting Room A]"},
{name: "id only", id: "omm_2", room: "", want: "omm_2"},
{name: "id only with whitespace name", id: "omm_3", room: " ", want: "omm_3"},
{name: "name only degrades to name", id: "", room: "Room B", want: "Room B"},
{name: "both blank returns empty", id: "", room: "", want: ""},
{name: "name with parens does not create ambiguous nesting", id: "omm_4", room: "Room A (west wing)", want: "omm_4[Room A (west wing)]"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := roomLabel(tt.id, tt.room); got != tt.want {
t.Errorf("roomLabel(%q, %q) = %q, want %q", tt.id, tt.room, got, tt.want)
}
})
}
}
// TestRecurringMasterEventID_Shapes pins the recurringMasterEventID contract:
// only `{uid}_{positive int}` collapses to `{uid}_0`; everything else opts out.
func TestRecurringMasterEventID_Shapes(t *testing.T) {
tests := []struct {
in string
wantID string
wantOK bool
scenario string
}{
{in: "abc_1742515200", wantID: "abc_0", wantOK: true, scenario: "positive suffix collapses to master"},
{in: "abc_1", wantID: "abc_0", wantOK: true, scenario: "positive one collapses to master"},
{in: "abc_0", wantID: "", wantOK: false, scenario: "already master"},
{in: "abc", wantID: "", wantOK: false, scenario: "no underscore"},
{in: "_1742515200", wantID: "", wantOK: false, scenario: "empty uid"},
{in: "abc_", wantID: "", wantOK: false, scenario: "empty suffix"},
{in: "abc_-1", wantID: "", wantOK: false, scenario: "negative suffix"},
{in: "abc_xyz", wantID: "", wantOK: false, scenario: "non-numeric suffix"},
{in: "abc_def_1742515200", wantID: "abc_def_0", wantOK: true, scenario: "uid may contain underscore"},
}
for _, tt := range tests {
t.Run(tt.scenario, func(t *testing.T) {
gotID, gotOK := recurringMasterEventID(tt.in)
if gotID != tt.wantID || gotOK != tt.wantOK {
t.Errorf("recurringMasterEventID(%q) = (%q, %v), want (%q, %v)", tt.in, gotID, gotOK, tt.wantID, tt.wantOK)
}
})
}
}
// TestUpdate_RoomCheck_EventNotFound_FallsBackToMaster pins the 193001
// fallback: when the event_id is `{uid}_{original_time}` and the server
// answers "event not found", the snapshot GET retries against `{uid}_0`
// (the recurring master), so the room-check pipeline can still proceed.
func TestUpdate_RoomCheck_EventNotFound_FallsBackToMaster(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// First GET on the instance event: 193001.
instanceStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_1742515200",
Body: map[string]interface{}{
"code": 193001,
"msg": "event not found",
},
}
reg.Register(instanceStub)
// Fallback GET on the master event: 200 with an existing room attendee, so
// the pre-check has something to reason about.
masterStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_0",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "uid_master_0",
"summary": "Weekly sync",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
"attendees": []interface{}{map[string]interface{}{"type": "resource", "room_id": "omm_from_master"}},
},
},
},
}
reg.Register(masterStub)
// Time change → precheck runs against existing room from the master snapshot.
precheckStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{
"room_id": "omm_from_master",
"status": "available",
},
},
},
},
}
reg.Register(precheckStub)
// PATCH succeeds.
patchStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/calendar/v4/calendars/cal_rc/events/uid_master_1742515200",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"event": map[string]interface{}{"event_id": "uid_master_1742515200"}},
},
}
reg.Register(patchStub)
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "uid_master_1742515200",
"--calendar-id", "cal_rc",
"--start", "2025-03-21T08:00:00+08:00",
"--end", "2025-03-21T09:00:00+08:00",
"--as", "bot",
}, f, nil)
if err != nil {
t.Fatalf("expected update to succeed after master fallback, got %v", err)
}
}
// TestApprovalReasonHint_ByMode pins the copy for each supported approval
// mode, including the over_duration current-vs-threshold branches. The exact
// phrase matters because agents parse it to decide next steps (relay to user,
// shorten the meeting, pick another room).
func TestApprovalReasonHint_ByMode(t *testing.T) {
tests := []struct {
name string
info *roomApprovalInfo
duration int64
mustContain []string
mustNotContain []string
}{
{
name: "all mode always needs approval",
info: &roomApprovalInfo{ApprovalMode: "all"},
duration: 3600,
mustContain: []string{
"requires approval for every reservation",
},
mustNotContain: []string{
"the CLI cannot submit approvals",
"lark-cli calendar event.attendees create",
},
},
{
name: "over_duration with current above threshold cites both",
info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
duration: 7200,
mustContain: []string{
"exceeds 1 hours",
"current duration is 2 hours",
},
mustNotContain: []string{
"lark-cli calendar event.attendees create",
},
},
{
name: "over_duration with current exactly at threshold treated as over",
info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
duration: 3600,
mustContain: []string{
"exceeds 1 hours",
"current duration is 1 hours",
},
},
{
name: "over_duration with current below threshold surfaces reconciliation",
info: &roomApprovalInfo{ApprovalMode: "over_duration", ApprovalDurationThreshold: "3600"},
duration: 1800,
mustContain: []string{
"exceeds 1 hours",
"current duration reads as 30 minutes",
"server still flagged approval",
},
},
{
name: "over_duration without threshold keeps mode label",
info: &roomApprovalInfo{ApprovalMode: "over_duration"},
duration: 3600,
mustContain: []string{
"exceeds a duration threshold",
},
mustNotContain: []string{
"the CLI cannot submit approvals",
},
},
{
name: "unknown mode falls back to generic reminder",
info: &roomApprovalInfo{ApprovalMode: "future_mode"},
duration: 3600,
mustContain: []string{
"requires approval before it can be booked",
},
},
{
name: "nil approval info still yields a reminder",
info: nil,
duration: 3600,
mustContain: []string{
"requires approval before it can be booked",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := approvalReasonHint(tt.info, tt.duration)
for _, needle := range tt.mustContain {
if !strings.Contains(got, needle) {
t.Errorf("approvalReasonHint(%+v, %d) missing %q, got: %q", tt.info, tt.duration, needle, got)
}
}
for _, needle := range tt.mustNotContain {
if strings.Contains(got, needle) {
t.Errorf("approvalReasonHint(%+v, %d) should not contain %q (that clause belongs in the hint, not the per-line reason), got: %q", tt.info, tt.duration, needle, got)
}
}
})
}
}
// TestUpdate_RoomCheck_NeedApproval_Blocks pins that a status=="need_approval"
// result blocks the update with a friendly, structured message: mode,
// threshold, current duration comparison, and the "CLI can't approve" clause.
// The block error also carries the same retry hint as the unavailable branch
// so agents don't auto-retry with --skip-room-check.
func TestUpdate_RoomCheck_NeedApproval_Blocks(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Snapshot window: 1742515200 -> 1742522400 (2h). Threshold is 1h, so the
// current duration is over threshold.
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_approval", "1742515200", "1742522400"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{
"room_id": "omm_approval",
"room_name": "Executive Room",
"status": "need_approval",
"room_approval_info": map[string]interface{}{
"approval_mode": "over_duration",
"approval_duration_threshold": "3600",
},
},
},
},
},
})
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc_approval",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_approval",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected need_approval to block the update")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
}
if !strings.Contains(ve.Message, "omm_approval[Executive Room]") {
t.Errorf("message should render room label, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "requires approval when the booking exceeds 1 hours") {
t.Errorf("message should carry approval threshold, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "current duration is 2 hours") {
t.Errorf("message should carry current-vs-threshold comparison, got: %q", ve.Message)
}
if strings.Contains(ve.Message, "the CLI cannot submit approvals inline") {
t.Errorf("recovery clause should live in the hint (not repeated per line in the message), got message: %q", ve.Message)
}
if strings.Contains(ve.Message, "lark-cli calendar event.attendees create --as user") {
t.Errorf("attendees-create recovery clause should live in the hint (not per line), got message: %q", ve.Message)
}
if !strings.Contains(ve.Hint, "the CLI cannot submit approvals") {
t.Errorf("hint should carry the approval recovery clause once, got: %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "DO NOT auto-run") {
t.Errorf("hint should forbid auto-running any approval recovery path without user confirmation, got: %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "ask the user first") {
t.Errorf("hint should require asking the user before picking a recovery path, got: %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "lark-cli calendar event.attendees create --as user") {
t.Errorf("hint should point at the attendees-create recovery path, got: %q", ve.Hint)
}
if !strings.Contains(ve.Hint, "update through the client") {
t.Errorf("hint should mention the client-side fallback for re-approval on existing rooms, got: %q", ve.Hint)
}
if !strings.Contains(ve.Hint, flagSkipRoomCheck) {
t.Errorf("hint should still mention --%s, got: %q", flagSkipRoomCheck, ve.Hint)
}
}
// TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent pins that when
// the API returns during_requisition without room_requisition, the recovery
// hint keeps the line coherent on its own.
func TestUpdate_RoomCheck_RequisitionMissingBoundsStillCoherent(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(eventSnapshotStub("cal_rc", "evt_rc_req2", "1742515200", "1742525200"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/freebusy/room_availability_check",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"room_availabilitys": []interface{}{
map[string]interface{}{
"room_id": "omm_req_nobounds",
"status": "unavailable",
"unavailable_reason_type": "during_requisition",
},
},
},
},
})
err := mountAndRun(t, CalendarUpdate, []string{
"+update",
"--event-id", "evt_rc_req2",
"--calendar-id", "cal_rc",
"--add-attendee-ids", "omm_req_nobounds",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected block error for during_requisition without bounds")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T (%v)", err, err)
}
if strings.Contains(ve.Message, "the disabled period") {
t.Errorf("message should not fabricate a disabled period, got: %q", ve.Message)
}
if !strings.Contains(ve.Message, "pick a different time or a different room") {
t.Errorf("message should always include recovery hint, got: %q", ve.Message)
}
}

View File

@@ -36,7 +36,6 @@ var CalendarUpdate = common.Shortcut{
{Name: "add-attendee-ids", Desc: "attendee IDs to add, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "remove-attendee-ids", Desc: "attendee IDs to remove, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "notify", Type: "bool", Default: "true", Desc: "send update notification to attendees"},
{Name: flagSkipRoomCheck, Type: "bool", Default: "false", Hidden: true, Desc: "skip meeting-room availability precheck (default checks rooms whenever a new room is added or the time/rrule of a room-attached event changes)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateCalendarUpdate(runtime)
@@ -220,50 +219,6 @@ func calendarUpdateAttendeesPath(calendarID, eventID string) string {
return calendarUpdateEventPath(calendarID, eventID) + "/attendees"
}
// runRoomAvailabilityPrecheck checks any room affected by this update (new
// room attendees, or existing rooms when the time/rrule shifts) against the
// server before the PATCH is issued. It returns nil to allow the update to
// proceed and a typed error to block it. Called only when --skip-room-check
// is false.
func runRoomAvailabilityPrecheck(ctx context.Context, runtime *common.RuntimeContext, calendarID, eventID string, body map[string]interface{}) error {
timeChanged := runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")
rruleChanged := runtime.Cmd.Flags().Changed("rrule")
var newStartTs, newEndTs string
if timeChanged {
if m, _ := body["start_time"].(map[string]string); m != nil {
newStartTs = m["timestamp"]
}
if m, _ := body["end_time"].(map[string]string); m != nil {
newEndTs = m["timestamp"]
}
}
plan, err := resolveRoomCheckPlan(ctx, runtime, calendarID, eventID, newStartTs, newEndTs, timeChanged, rruleChanged)
if err != nil {
return err
}
if plan == nil {
return nil
}
results, err := callRoomAvailabilityCheck(runtime, buildRoomCheckBody(calendarID, eventID, plan))
if err != nil {
// Degrade gracefully: warn on stderr and let the update proceed so the
// pre-check API doesn't gate legitimate updates when it hiccups. For
// 190014 (invalid_parameters) surface the server-supplied field-level
// detail so agents can see why the precheck refused.
msg := unwrapCalendarAPIError(err)
if msg == "" {
msg = err.Error()
}
fmt.Fprintf(runtime.IO().ErrOut,
"[calendar +update] warning: room availability check failed (%s); proceeding with update — pass --%s to silence\n",
msg, flagSkipRoomCheck)
return nil
}
return blockOnUnavailableRooms(results, roomCheckPlanDurationSec(plan))
}
func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
calendarID, eventID := calendarUpdateIDs(runtime)
displayCalendarID := calendarID
@@ -291,33 +246,6 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
d.Desc("multi-step update: event fields, attendee removal, and attendee addition run in order when requested")
}
steps := 0
if !runtime.Bool(flagSkipRoomCheck) {
newRooms := collectAttendeeRoomIDs(runtime.Str("add-attendee-ids"))
timeChanged := runtime.Cmd.Flags().Changed("start") && runtime.Cmd.Flags().Changed("end")
rruleChanged := runtime.Cmd.Flags().Changed("rrule")
if len(newRooms) > 0 || timeChanged || rruleChanged {
steps++
desc := fmt.Sprintf("[%d] Pre-check meeting room availability (default; pass --%s to skip)", steps, flagSkipRoomCheck)
previewBody := map[string]interface{}{
"calendar_id": displayCalendarID,
"event_id": eventID,
"room_ids": newRooms,
"start_timezone": "<inherited from event>",
}
if start, _ := body["start_time"].(map[string]string); start != nil {
previewBody["start_time"] = formatRoomCheckTime(start["timestamp"], time.Local)
}
if end, _ := body["end_time"].(map[string]string); end != nil {
previewBody["end_time"] = formatRoomCheckTime(end["timestamp"], time.Local)
}
if rrule, _ := body["recurrence"].(string); rrule != "" {
previewBody["event_rrule"] = rrule
}
d.POST(roomCheckPath).Desc(desc).Body(previewBody)
}
}
if hasEventFields {
steps++
d.PATCH("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
@@ -350,7 +278,7 @@ func dryRunCalendarUpdate(runtime *common.RuntimeContext) *common.DryRunAPI {
return d
}
func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
func executeCalendarUpdate(_ context.Context, runtime *common.RuntimeContext) error {
calendarID, eventID := calendarUpdateIDs(runtime)
if eventID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
@@ -361,12 +289,6 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return err
}
if !runtime.Bool(flagSkipRoomCheck) {
if err := runRoomAvailabilityPrecheck(ctx, runtime, calendarID, eventID, body); err != nil {
return err
}
}
completed := []string{}
event := map[string]interface{}{}
if hasEventFields {

View File

@@ -14,10 +14,11 @@ import (
)
const (
PermissionGrantGranted = "granted"
PermissionGrantSkipped = "skipped"
PermissionGrantFailed = "failed"
permissionGrantPerm = "full_access"
PermissionGrantGranted = "granted"
PermissionGrantSkipped = "skipped"
PermissionGrantFailed = "failed"
permissionGrantPerm = "full_access"
permissionGrantPermHint = "可管理权限"
)
// AutoGrantCurrentUserDrivePermission grants full_access on a newly created
@@ -120,7 +121,7 @@ func buildPermissionGrantResult(status, userOpenID, message, reason string) map[
}
func permissionGrantPermMessage() string {
return permissionGrantPerm
return permissionGrantPerm + " (" + permissionGrantPermHint + ")"
}
func permissionGrantPermType(resourceType string) string {

View File

@@ -31,14 +31,6 @@ func apiErrWithScopes(code int, msg string, subjects ...string) error {
return errclass.BuildAPIError(resp, errclass.ClassifyContext{})
}
func TestPermissionGrantPermMessageUsesAPINameOnly(t *testing.T) {
t.Parallel()
if got := permissionGrantPermMessage(); got != "full_access" {
t.Fatalf("permissionGrantPermMessage() = %q, want %q", got, "full_access")
}
}
func TestAutoGrantStderrWarning_SkippedNoUser(t *testing.T) {
config := &core.CliConfig{
AppID: "perm-grant-test-skip",

View File

@@ -63,7 +63,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access on the new document." {
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -173,9 +173,11 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
wantMessage := "Resource was created, but granting current user full_access failed: no permission. You can retry later or continue using bot identity."
if grant["message"] != wantMessage {
t.Fatalf("permission_grant.message = %q, want %q", grant["message"], wantMessage)
if !strings.Contains(grant["message"].(string), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
if !strings.Contains(grant["message"].(string), "retry later") {
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())

View File

@@ -59,7 +59,7 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D
}
desc := "OpenAPI: create document"
if runtime.IsBot() {
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new document."
desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document."
}
return common.NewDryRunAPI().
POST("/open-apis/docs_ai/v1/documents").

View File

@@ -73,10 +73,10 @@ func init() {
registerIMMarkdownHandler("time", handleIMMarkdownDiscard)
registerIMMarkdownHandler("whiteboard", handleIMMarkdownInlineCode)
registerIMMarkdownHandler("sheet", handleIMMarkdownSheet)
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("Task", "task-id", "guid", "token", "id"))
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("Chat card", "chat-id", "chat_id", "id"))
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("Base"))
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("Base"))
registerIMMarkdownHandler("task", handleIMMarkdownConditionalResourceLabel("任务", "task-id", "guid", "token", "id"))
registerIMMarkdownHandler("chat_card", handleIMMarkdownConditionalResourceLabel("群聊卡片", "chat-id", "chat_id", "id"))
registerIMMarkdownHandler("bitable", handleIMMarkdownResourceLabel("多维表格"))
registerIMMarkdownHandler("base_refer", handleIMMarkdownResourceLabel("多维表格"))
registerIMMarkdownHandler("okr", handleIMMarkdownResourceLabel("OKR"))
registerIMMarkdownHandler("poll", handleIMMarkdownDiscard)
registerIMMarkdownHandler("agenda", handleIMMarkdownDiscard)

View File

@@ -975,8 +975,8 @@ func TestConvertToIMMarkdownDocumentExpectedTagsAndEscaping(t *testing.T) {
"````Go\nfmt.Println(\"hi\")\n```\n````",
"`` `edge` `` $E=mc^2$ --- ![A \\[img\\]](https://example.com/i%281%29.png)",
"``report`v1`.pdf``",
"`Task``Chat card`",
"`Base``Base``OKR`",
"`任务``群聊卡片`",
"`多维表格``多维表格``OKR`",
}, "\n")
if got := convertToIMMarkdown(input, imCtx); got != want {

View File

@@ -26,7 +26,7 @@ func v2FetchFlags() []common.Flag {
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|error"},
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},

View File

@@ -443,7 +443,7 @@ func TestValidateReadModeFlagsAcceptsValidScopeOptions(t *testing.T) {
name: "keyword with keyword",
setFlags: map[string]string{
"scope": "keyword",
"keyword": "bug|error",
"keyword": "bug|缺陷",
},
},
{

View File

@@ -24,7 +24,7 @@ var validCommandsV2 = map[string]bool{
"append": true,
}
const docsReferenceMapFlagDesc = "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。"
const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc

View File

@@ -19,8 +19,6 @@ import (
)
func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
wantDesc := "Structured `reference_map` JSON object; must be used with `--content`. Prefer embedding structure directly in the document body for ordinary writes; use `--reference-map` primarily to preserve or replay an existing `document.reference_map`. Accepts inline JSON, `@reference-map.json` (relative path), or `-` to read from stdin."
for name, flags := range map[string][]common.Flag{
"create": v2CreateFlags(),
"update": v2UpdateFlags(),
@@ -36,8 +34,8 @@ func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) {
if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) {
t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input)
}
if flag.Desc != wantDesc {
t.Fatalf("reference-map help = %q, want English description %q", flag.Desc, wantDesc)
if !strings.Contains(flag.Desc, "@reference-map.json") {
t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc)
}
})
}

View File

@@ -772,7 +772,7 @@ func parseCommentReplyElements(raw string) ([]map[string]interface{}, error) {
var inputs []commentReplyElementInput
if err := json.Unmarshal([]byte(raw), &inputs); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"Example text\"}]'", err).WithParam("--content")
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is not valid JSON: %s\nexample: --content '[{\"type\":\"text\",\"text\":\"文本信息\"}]'", err).WithParam("--content")
}
if len(inputs) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must contain at least one reply element").WithParam("--content")

View File

@@ -59,7 +59,7 @@ var DriveCreateFolder = common.Shortcut{
Desc("[1] Create folder").
Body(spec.RequestBody())
if runtime.IsBot() {
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder.")
dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.")
}
return dry
},

View File

@@ -90,7 +90,6 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Desc string `json:"desc"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
@@ -109,10 +108,6 @@ func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) {
if got.API[0].Body["folder_token"] != "fld_parent" {
t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent")
}
wantDesc := "After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new folder."
if got.API[0].Desc != wantDesc {
t.Fatalf("desc = %q, want %q", got.API[0].Desc, wantDesc)
}
}
func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
@@ -183,7 +178,7 @@ func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access on the new folder." {
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}

View File

@@ -114,7 +114,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
Desc("[3] Poll import task result").
Set("ticket", "<ticket>")
if runtime.IsBot() {
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it.")
dry.Desc("After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on it.")
}
return dry

View File

@@ -95,7 +95,7 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveImport.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
@@ -108,7 +108,6 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
var got struct {
API []struct {
Desc string `json:"desc"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
@@ -118,10 +117,6 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
}
wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
if got.API[len(got.API)-1].Desc != wantDesc {
t.Fatalf("desc = %q, want %q", got.API[len(got.API)-1].Desc, wantDesc)
}
if got.API[0].Body != nil {
t.Fatalf("wiki probe should not have a request body, got %#v", got.API[0].Body)

View File

@@ -1088,7 +1088,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
t.Fatalf("set --wiki-token: %v", err)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot)
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil)
dry := DriveUpload.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
@@ -1100,8 +1100,7 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
}
var got struct {
PostUploadNote string `json:"post_upload_note"`
API []struct {
API []struct {
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
@@ -1124,10 +1123,6 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
}
wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
if got.PostUploadNote != wantPostUploadNote {
t.Fatalf("post_upload_note = %q, want %q", got.PostUploadNote, wantPostUploadNote)
}
}
func TestNewDriveUploadSpecPreservesPathAndName(t *testing.T) {

View File

@@ -65,7 +65,7 @@ func TestDriveUploadBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access on the new file." {
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new file." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}

View File

@@ -103,7 +103,7 @@ var DriveUpload = common.Shortcut{
"Omit both --folder-token and --wiki-token to upload into the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to upload under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"Pass --file-token <file_token> to overwrite an existing Drive file in place; the shortcut forwards file_token to the upload API.",
"In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
"In bot mode, automatic full_access (可管理权限) grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveUploadSpec(runtime, newDriveUploadSpec(runtime))
@@ -137,7 +137,7 @@ var DriveUpload = common.Shortcut{
"with_url": true,
})
if runtime.IsBot() && !isOverwrite {
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file.")
d.Set("post_upload_note", "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new file.")
}
return d
},

View File

@@ -5,7 +5,6 @@ package drive
import (
"reflect"
"strings"
"testing"
)
@@ -72,18 +71,3 @@ func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) {
t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want)
}
}
func TestDriveUploadHelpTipUsesEnglishPermissionName(t *testing.T) {
t.Parallel()
want := "In bot mode, automatic full_access grant only applies to newly uploaded files; overwrite via --file-token does not modify existing file permissions."
for _, tip := range DriveUpload.Tips {
if strings.Contains(tip, "automatic full_access") {
if tip != want {
t.Fatalf("DriveUpload full_access tip = %q, want %q", tip, want)
}
return
}
}
t.Fatal("DriveUpload full_access help tip not found")
}

View File

@@ -64,7 +64,7 @@ func TestSheetCreateBotAutoGrantSuccess(t *testing.T) {
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access on the new spreadsheet." {
if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new spreadsheet." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
@@ -156,26 +156,10 @@ func TestSheetCreateDryRunIncludesFolderToken(t *testing.T) {
"data": "",
},
nil, nil)
rt = common.TestNewRuntimeContextWithIdentity(rt.Cmd, nil, core.AsBot)
got := mustMarshalSheetsDryRun(t, SheetCreate.DryRun(context.Background(), rt))
if !strings.Contains(got, `"folder_token":"fldcn123"`) {
t.Fatalf("DryRun should include folder_token, got: %s", got)
}
var dryRun struct {
API []struct {
Desc string `json:"desc"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(got), &dryRun); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
if len(dryRun.API) != 1 {
t.Fatalf("dry-run API count = %d, want 1", len(dryRun.API))
}
wantDesc := "After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet."
if dryRun.API[0].Desc != wantDesc {
t.Fatalf("desc = %q, want %q", dryRun.API[0].Desc, wantDesc)
}
}
func TestSheetCreatePreservesBackendURL(t *testing.T) {

View File

@@ -115,7 +115,7 @@ var SheetCreate = common.Shortcut{
POST("/open-apis/sheets/v3/spreadsheets").
Body(body)
if runtime.IsBot() {
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new spreadsheet.")
d.Desc("After spreadsheet creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new spreadsheet.")
}
return d
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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