Compare commits

..

24 Commits

Author SHA1 Message Date
zhengzhijie
21c2e3950e feat(sheets): add chart data update shortcut 2026-07-23 17:12:08 +08:00
zhengzhijie
080fb57ad0 fix(sheets): normalize irregular chart ranges 2026-07-23 16:00:57 +08:00
zhengzhijie
87aa303ca1 fix(sheets): normalize chart range and flag inputs 2026-07-23 16:00:57 +08:00
zhengzhijie
c8f57c659e fix(sheets): prefer semantic chart shortcuts 2026-07-22 18:13:45 +08:00
zhengzhijie
e81e42029f feat(sheets): improve semantic chart workflows 2026-07-22 16:52:48 +08:00
zhengzhijie
e303da4b5e feat(sheets): add semantic chart shortcuts 2026-07-22 11:21:55 +08:00
zhengzhijie
f6bbd86303 feat(sheets): support partial chart snapshot schemas 2026-07-20 21:34:25 +08:00
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

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

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

## Changes

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

## Notes

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

* ci: preserve live E2E cleanup on supersession

* ci: harden live E2E supersession check

* ci: gate live E2E on dry-run planning

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

* ci: bound dry-run E2E planning

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

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

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

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

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

* fix: narrow drive delete tolerance to the verified transient

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

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

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

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

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

* docs(base): update field default value schema
2026-07-17 15:17:01 +08:00
zhanghuanxu
4e0a6a988c docs(slides): document table dimensions 2026-07-17 11:36:53 +08:00
108 changed files with 10547 additions and 3550 deletions

View File

@@ -1,4 +1,5 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -8,6 +9,12 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -295,6 +302,11 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -308,6 +320,23 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -341,16 +370,22 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
LARKSUITE_CLI_BRAND: feishu
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -361,31 +396,68 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
id: build_cli
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -395,7 +467,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -472,8 +544,8 @@ jobs:
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a fork, license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown

View File

@@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file.
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [v1.0.71] - 2026-07-16
### Features
@@ -1527,6 +1552,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69

View File

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

164
scripts/fetch_e2e_tat.js Normal file
View File

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

View File

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

View File

@@ -223,6 +223,24 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
},
{
shortcut: "+chart-create-basic",
sc: ChartCreateBasic,
args: []string{"--sheet-id", "sh1", "--chart-type", "column", "--data-range", "A1:C10", "--title", "Sales", "--data-labels", "value", "--anchor-cell", "F2"},
subInput: `{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales","data-labels":"value","anchor-cell":"F2"}`,
},
{
shortcut: "+chart-config-update",
sc: ChartConfigUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--title", "Updated", "--data-labels", "category", "--data-label-position", "top"},
subInput: `{"sheet-id":"sh1","chart-id":"c1","title":"Updated","data-labels":"category","data-label-position":"top"}`,
},
{
shortcut: "+chart-data-update",
sc: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "'Sheet1'!A1:M6", "--data-direction", "column", "--dim1-index", "1", "--dim2-indexes", "4,8"},
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"'Sheet1'!A1:M6","data-direction":"column","dim1-index":1,"dim2-indexes":"4,8"}`,
},
{
shortcut: "+pivot-create",
sc: PivotCreate,
@@ -424,6 +442,22 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
subInput: `{}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+chart-data-update invalid dim1 index",
shortcut: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "0"},
subShortcut: "+chart-data-update",
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":0}`,
wantContains: "--dim1-index must be a positive 1-based index",
},
{
name: "+chart-data-update dim1 and dim2 conflict",
shortcut: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "2", "--dim2-indexes", "2,3"},
subShortcut: "+chart-data-update",
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":2,"dim2-indexes":"2,3"}`,
wantContains: "--dim2-indexes must not contain the dim1 index 2",
},
{
name: "+float-image-create both image-token and image-uri",
shortcut: FloatImageCreate,
@@ -662,6 +696,18 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
"--chart-id is required",
},
{
"+chart-data-update missing --chart-id",
"+chart-data-update",
`{"sheet-id":"sh1","data-range":"A1:C4"}`,
"--chart-id is required",
},
{
"+chart-data-update missing --data-range",
"+chart-data-update",
`{"sheet-id":"sh1","chart-id":"c1"}`,
"--data-range is required",
},
{
"+filter-create missing --range",
"+filter-create",

View File

@@ -168,9 +168,12 @@ var batchOpDispatch = map[string]batchOpMapping{
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+chart-create-basic": {"manage_chart_object", chartCreateBasicInput},
"+chart-config-update": {"manage_chart_object", chartConfigUpdateInput},
"+chart-data-update": {"manage_chart_object", chartDataUpdateInput},
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},

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

@@ -1277,13 +1277,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,9 +1292,16 @@
"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",
@@ -1400,9 +1408,16 @@
"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 +1480,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",
@@ -3219,6 +3249,559 @@
}
]
},
"+chart-create-basic": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-type",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Chart type",
"enum": [
"column",
"bar",
"line",
"area",
"pie",
"scatter",
"combo",
"radar"
]
},
{
"name": "data-range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"
},
{
"name": "data-direction",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data series direction; column uses the first column as categories, row uses the first row",
"default": "column",
"enum": [
"column",
"row"
]
},
{
"name": "title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart title"
},
{
"name": "subtitle",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart subtitle"
},
{
"name": "legend-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Legend position; hidden removes the legend",
"enum": [
"top",
"bottom",
"left",
"right",
"hidden"
]
},
{
"name": "x-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "X-axis title"
},
{
"name": "y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Left Y-axis title"
},
{
"name": "secondary-y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Right Y-axis title"
},
{
"name": "x-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "X-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "y-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Left Y-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "data-labels",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
"enum": [
"none",
"value",
"percentage",
"value_percentage",
"category_percentage",
"category",
"series"
]
},
{
"name": "data-label-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label position",
"enum": [
"auto",
"top",
"bottom",
"left",
"right",
"center",
"inside",
"outside"
]
},
{
"name": "stack",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Stacking mode",
"enum": [
"none",
"normal",
"percent"
]
},
{
"name": "stacked",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Compatibility alias for --stack normal",
"hidden": true
},
{
"name": "smooth",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
},
{
"name": "color-palette",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
"enum": [
"brandColorSeries@v2",
"rainbowColorSeries@v2",
"complementaryColorSeries@v2",
"converseColorSeries@v2",
"primaryColorSeries@v2",
"singleColorSeries-B-@v2",
"singleColorSeries-W-@v2",
"singleColorSeries-G-@v2",
"singleColorSeries-Y-@v2",
"singleColorSeries-O-@v2",
"singleColorSeries-R-@v2",
"singleColorSeries-D-@v2"
]
},
{
"name": "colors",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
},
{
"name": "anchor-cell",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Optional chart anchor cell such as F2; defaults to the right of the data range"
},
{
"name": "width",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Optional chart width; must be paired with --height"
},
{
"name": "height",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Optional chart height; must be paired with --width"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-config-update": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target chart reference_id"
},
{
"name": "title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart title"
},
{
"name": "subtitle",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart subtitle"
},
{
"name": "legend-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Legend position; hidden removes the legend",
"enum": [
"top",
"bottom",
"left",
"right",
"hidden"
]
},
{
"name": "x-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "X-axis title"
},
{
"name": "y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Left Y-axis title"
},
{
"name": "secondary-y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Right Y-axis title"
},
{
"name": "x-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "X-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "y-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Left Y-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "data-labels",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
"enum": [
"none",
"value",
"percentage",
"value_percentage",
"category_percentage",
"category",
"series"
]
},
{
"name": "data-label-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label position",
"enum": [
"auto",
"top",
"bottom",
"left",
"right",
"center",
"inside",
"outside"
]
},
{
"name": "stack",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Stacking mode",
"enum": [
"none",
"normal",
"percent"
]
},
{
"name": "stacked",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Compatibility alias for --stack normal",
"hidden": true
},
{
"name": "smooth",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
},
{
"name": "color-palette",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
"enum": [
"brandColorSeries@v2",
"rainbowColorSeries@v2",
"complementaryColorSeries@v2",
"converseColorSeries@v2",
"primaryColorSeries@v2",
"singleColorSeries-B-@v2",
"singleColorSeries-W-@v2",
"singleColorSeries-G-@v2",
"singleColorSeries-Y-@v2",
"singleColorSeries-O-@v2",
"singleColorSeries-R-@v2",
"singleColorSeries-D-@v2"
]
},
{
"name": "colors",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-data-update": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target chart reference_id"
},
{
"name": "data-range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"
},
{
"name": "data-direction",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data series direction; defaults to the existing chart direction when omitted",
"enum": [
"column",
"row"
]
},
{
"name": "dim1-index",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "1-based category/X-axis dimension index within the data range; defaults to the first dimension"
},
{
"name": "dim2-indexes",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-create": {
"risk": "write",
"flags": [
@@ -3313,7 +3896,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)",
"desc": "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole",
"input": [
"file",
"stdin"

File diff suppressed because it is too large Load Diff

View File

@@ -75,8 +75,9 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (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`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -199,6 +200,32 @@ var flagDefs = map[string]commandDef{
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-config-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -210,6 +237,52 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-create-basic": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-type", Kind: "own", Type: "string", Required: "required", Desc: "Chart type", Enum: []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}},
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; column uses the first column as categories, row uses the first row", Default: "column", Enum: []string{"column", "row"}},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "anchor-cell", Kind: "own", Type: "string", Required: "optional", Desc: "Optional chart anchor cell such as F2; defaults to the right of the data range"},
{Name: "width", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart width; must be paired with --height"},
{Name: "height", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart height; must be paired with --width"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-data-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; defaults to the existing chart direction when omitted", Enum: []string{"column", "row"}},
{Name: "dim1-index", Kind: "own", Type: "int", Required: "optional", Desc: "1-based category/X-axis dimension index within the data range; defaults to the first dimension"},
{Name: "dim2-indexes", Kind: "own", Type: "string", Required: "optional", Desc: "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
@@ -241,7 +314,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -317,7 +390,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "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: "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"},
@@ -983,6 +1057,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

@@ -38,9 +38,95 @@ 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"},
"+cols-resize": {"cols": "range"},
"+rows-resize": {"rows": "range"},
"+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",
},
"+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
@@ -67,6 +153,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 +233,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

@@ -284,9 +284,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 +294,158 @@ 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("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,133 @@ 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 / anyOf 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 / anyOf 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
}
}
}
if branches, ok := m["anyOf"].([]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 / anyOf 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)
}
}
if branches, ok := m["anyOf"].([]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

@@ -407,6 +407,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 +424,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)
@@ -533,8 +626,11 @@ func normalizeCellStyleAliases(style map[string]interface{}, path string) error
// 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.
// It also expands the border "all" shorthand and intercepts border_styles
// mis-nested inside cell_styles — both server-rejected shapes that eval
// traces show surviving CLI validation and costing a full network round
// trip. Structure is checked leniently to match the pass-through contract:
// any element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
@@ -546,10 +642,18 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
if !ok {
continue
}
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
expandBorderAllShorthand(bs)
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if _, misNested := st["border_styles"]; misNested {
return common.ValidationErrorf(
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
path, r, c)
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
@@ -558,8 +662,29 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
return nil
}
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
// Excel / openpyxl vocabulary, rejected by the backend — into the four
// explicit sides, in place. An explicitly set side wins over the shorthand.
// Applied on both the typed --cells path and the --styles path, so batch
// sub-ops get the same rewrite as standalone calls.
func expandBorderAllShorthand(border map[string]interface{}) {
all, ok := border["all"]
if !ok {
return
}
for _, side := range []string{"top", "bottom", "left", "right"} {
if _, exists := border[side]; !exists {
border[side] = all
}
}
delete(border, "all")
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
// left/right with style sub-objects), expanding the "all" side shorthand the
// same as the typed --cells and --styles paths so +cells-set-style /
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
// Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
@@ -572,6 +697,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
expandBorderAllShorthand(m)
return m, nil
}

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

@@ -83,6 +83,7 @@ var BatchUpdate = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
"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).",
},
@@ -160,6 +161,10 @@ var CellsBatchSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-set-style"),
Tips: []string{
`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

View File

@@ -0,0 +1,605 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var chartHexColorPattern = regexp.MustCompile(`^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
var chartSemanticConfigFlags = []string{
"title",
"subtitle",
"legend-position",
"x-axis-title",
"y-axis-title",
"secondary-y-axis-title",
"x-axis-label-angle",
"y-axis-label-angle",
"data-labels",
"data-label-position",
"stack",
"color-palette",
}
// ChartCreateBasic creates a complete server-side chart snapshot from a chart
// type and a rectangular source range. The CLI only forwards semantic input;
// it deliberately does not own or duplicate the full chart snapshot template.
var ChartCreateBasic = common.Shortcut{
Service: "sheets",
Command: "+chart-create-basic",
Description: "Create a basic chart from a chart type and data range; the server builds and validates the full snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-create-basic"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartCreateBasicInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartCreateBasicInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartCreateBasicInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartConfigUpdate updates the common chart settings that repeatedly caused
// full-snapshot retries in eval traces. Advanced per-series and marker styling
// remains on +chart-update --properties.
var ChartConfigUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-config-update",
Description: "Update common chart titles, axes, legend, labels, stacking, smoothing, or chart-level colors without sending a snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-config-update"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartDataUpdate rebinds an existing chart to a new source range. The server
// reads the current snapshot, rebuilds its data mapping, and preserves the
// chart's layout and visual configuration.
var ChartDataUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-data-update",
Description: "Update an existing chart's data range or direction while preserving its layout and visual configuration.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-data-update"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartDataUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartDataUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartDataUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartType := strings.TrimSpace(rt.Str("chart-type"))
if chartType == "" {
return nil, sheetsValidationForFlag("chart-type", "--chart-type is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
direction := rt.Str("data-direction")
if direction == "" {
direction = "column"
}
normalizedDataRange, dimensionCount, dataPointCount, err := normalizeBasicChartDataRanges(dataRange, direction)
if err != nil {
return nil, err
}
if dimensionCount < 2 || dataPointCount < 2 {
return nil, sheetsValidationForFlag("data-range", "--data-range must provide at least 2 data points and 2 dimensions")
}
if chartType == "combo" && dimensionCount < 3 {
return nil, sheetsValidationForFlag("data-range", "combo chart requires at least 3 rows or columns along --data-direction")
}
basic := map[string]interface{}{
"chart_type": chartType,
"data_range": normalizedDataRange,
}
if rt.Changed("data-direction") {
basic["data_direction"] = rt.Str("data-direction")
}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, basic)
if rt.Changed("anchor-cell") {
anchor := strings.TrimSpace(rt.Str("anchor-cell"))
_, row, ok := splitCellRef(anchor)
if !ok {
return nil, sheetsValidationForFlag("anchor-cell", "--anchor-cell must be a single A1 cell such as F2")
}
colEnd := 0
for colEnd < len(anchor) && ((anchor[colEnd] >= 'A' && anchor[colEnd] <= 'Z') || (anchor[colEnd] >= 'a' && anchor[colEnd] <= 'z')) {
colEnd++
}
basic["position"] = map[string]interface{}{"row": row, "col": strings.ToUpper(anchor[:colEnd])}
}
widthChanged := rt.Changed("width")
heightChanged := rt.Changed("height")
if widthChanged != heightChanged {
return nil, common.ValidationErrorf("--width and --height must be provided together").WithParams(
sheetsInvalidParam("width", "must be paired with --height"),
sheetsInvalidParam("height", "must be paired with --width"),
)
}
if widthChanged {
if rt.Int("width") < 10 || rt.Int("height") < 10 {
return nil, common.ValidationErrorf("--width and --height must be at least 10")
}
basic["size"] = map[string]interface{}{"width": rt.Int("width"), "height": rt.Int("height")}
}
input := map[string]interface{}{"excel_id": token, "operation": "create", "basic_chart": basic}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
updates := map[string]interface{}{}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, updates)
if len(updates) == 0 {
return nil, common.ValidationErrorf("at least one chart configuration flag is required")
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"config_updates": updates,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartDataUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
explicitSheet := ""
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
if item.sheet != "" {
if explicitSheet != "" && item.sheet != explicitSheet {
return nil, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
}
explicitSheet = item.sheet
}
}
updates := map[string]interface{}{"data_range": dataRange}
if rt.Changed("data-direction") {
updates["data_direction"] = rt.Str("data-direction")
}
dim1Index := 1
if rt.Changed("dim1-index") {
dim1Index = rt.Int("dim1-index")
if dim1Index < 1 {
return nil, sheetsValidationForFlag("dim1-index", "--dim1-index must be a positive 1-based index")
}
updates["dim1_index"] = dim1Index
}
if rt.Changed("dim2-indexes") {
dim2Indexes, parseErr := parseChartDim2Indexes(rt.Str("dim2-indexes"))
if parseErr != nil {
return nil, sheetsValidationForFlag("dim2-indexes", "%v", parseErr)
}
for _, index := range dim2Indexes {
if index == dim1Index {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must not contain the dim1 index %d",
dim1Index,
)
}
}
updates["dim2_indexes"] = dim2Indexes
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"data_updates": updates,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func parseChartDim2Indexes(raw string) ([]int, error) {
parts := strings.Split(raw, ",")
indexes := make([]int, 0, len(parts))
seen := make(map[int]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
return nil, common.ValidationErrorf("--dim2-indexes must be a comma-separated list of positive 1-based indexes")
}
index, err := strconv.Atoi(value)
if err != nil || index < 1 {
return nil, common.ValidationErrorf("--dim2-indexes must contain only positive 1-based indexes")
}
if _, exists := seen[index]; exists {
return nil, common.ValidationErrorf("--dim2-indexes must not contain duplicate index %d", index)
}
seen[index] = struct{}{}
indexes = append(indexes, index)
}
return indexes, nil
}
type chartDataRange struct {
sheet string
row, col int
rowCount, colCount int
}
func normalizeBasicChartDataRanges(dataRange, direction string) (normalized string, dimensionCount, dataPointCount int, err error) {
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
parsed := make([]chartDataRange, 0, len(ranges))
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
parsed = append(parsed, item)
}
first := parsed[0]
explicitSheet := ""
spans := make([][2]int, 0, len(parsed))
aligned := true
minRow, minCol := first.row, first.col
maxRow, maxCol := first.row+first.rowCount, first.col+first.colCount
for _, item := range parsed {
if item.sheet != "" {
if explicitSheet != "" && item.sheet != explicitSheet {
return "", 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
}
explicitSheet = item.sheet
}
if direction == "row" {
if item.col != first.col || item.colCount != first.colCount {
aligned = false
}
dimensionCount += item.rowCount
spans = append(spans, [2]int{item.row, item.row + item.rowCount})
} else {
if item.row != first.row || item.rowCount != first.rowCount {
aligned = false
}
dimensionCount += item.colCount
spans = append(spans, [2]int{item.col, item.col + item.colCount})
}
minRow = min(minRow, item.row)
minCol = min(minCol, item.col)
maxRow = max(maxRow, item.row+item.rowCount)
maxCol = max(maxCol, item.col+item.colCount)
}
overlapping := false
for i, current := range spans {
for j := 0; j < i; j++ {
if current[0] < spans[j][1] && spans[j][0] < current[1] {
overlapping = true
}
}
}
normalized = strings.Join(ranges, ",")
if len(ranges) > 1 && (!aligned || overlapping) {
prefix := ""
if explicitSheet != "" {
prefix = explicitSheet + "!"
}
normalized = prefix + columnIndexToLetter(minCol) + strconv.Itoa(minRow+1) + ":" + columnIndexToLetter(maxCol-1) + strconv.Itoa(maxRow)
dimensionCount = maxCol - minCol
dataPointCount = maxRow - minRow
if direction == "row" {
dimensionCount, dataPointCount = dataPointCount, dimensionCount
}
return normalized, dimensionCount, dataPointCount, nil
}
if direction == "row" {
dataPointCount = first.colCount
} else {
dataPointCount = first.rowCount
}
return normalized, dimensionCount, dataPointCount, nil
}
func splitChartDataRanges(value string) ([]string, error) {
var ranges []string
start := 0
inQuote := false
for i := 0; i <= len(value); i++ {
if i < len(value) && value[i] == '\'' {
if inQuote && i+1 < len(value) && value[i+1] == '\'' {
i++
} else {
inQuote = !inQuote
}
}
if i == len(value) || (value[i] == ',' && !inQuote) {
part := strings.TrimSpace(value[start:i])
if part == "" {
return nil, common.ValidationErrorf("range list contains an empty item")
}
ranges = append(ranges, part)
start = i + 1
}
}
if inQuote {
return nil, common.ValidationErrorf("unterminated quoted sheet name")
}
return ranges, nil
}
func parseChartDataRange(value string) (chartDataRange, error) {
item := chartDataRange{}
ref := strings.TrimSpace(value)
if bang := strings.LastIndex(ref, "!"); bang >= 0 {
item.sheet = strings.TrimSpace(ref[:bang])
ref = strings.TrimSpace(ref[bang+1:])
}
parts := strings.SplitN(ref, ":", 2)
if len(parts) != 2 {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
startCol, startRow, startOK := splitCellRef(parts[0])
endCol, endRow, endOK := splitCellRef(parts[1])
if !startOK || !endOK || endCol < startCol || endRow < startRow {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
item.row, item.col = startRow, startCol
item.rowCount, item.colCount = endRow-startRow+1, endCol-startCol+1
return item, nil
}
func configureChartSemanticCommand(cmd *cobra.Command) {
if cmd.Flags().Lookup("stacked") == nil {
cmd.Flags().Bool("stacked", false, "compatibility alias for --stack normal")
_ = cmd.Flags().MarkHidden("stacked")
}
originalArgs := cmd.Args
cmd.Args = func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && cmd.Flags().Changed("smooth") && (args[0] == "true" || args[0] == "false") {
return cmd.Flags().Set("smooth", args[0])
}
return originalArgs(cmd, args)
}
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
message := err.Error()
if strings.Contains(message, "unknown flag: --stacked") {
return sheetsValidationForFlag("stacked", "--stacked is not supported; use --stack normal (or --stack percent for 100%% stacking)")
}
return err
})
}
func addChartSemanticConfig(rt flagView, out map[string]interface{}) {
for _, flag := range chartSemanticConfigFlags {
if !rt.Changed(flag) {
continue
}
key := strings.ReplaceAll(flag, "-", "_")
if flag == "x-axis-label-angle" || flag == "y-axis-label-angle" {
out[key] = rt.Int(flag)
} else if flag == "data-labels" && rt.Str(flag) == "category_percentage" {
out[key] = "value_percentage"
} else {
out[key] = rt.Str(flag)
}
}
if rt.Changed("stacked") {
out["stack"] = "normal"
}
if rt.Changed("smooth") {
out["smooth"] = rt.Bool("smooth")
}
if rt.Changed("colors") {
out["colors"] = normalizedChartColors(rt)
}
}
func validateChartSemanticEnums(rt flagView) error {
if rt.Changed("stack") && rt.Changed("stacked") {
return common.ValidationErrorf("--stack and --stacked are mutually exclusive").WithParams(
sheetsInvalidParam("stack", "cannot be used with --stacked"),
sheetsInvalidParam("stacked", "cannot be used with --stack"),
)
}
return nil
}
func validateChartColorFlags(rt flagView) error {
if rt.Changed("color-palette") && rt.Changed("colors") {
return common.ValidationErrorf("--color-palette and --colors are mutually exclusive").WithParams(
sheetsInvalidParam("color-palette", "cannot be used with --colors"),
sheetsInvalidParam("colors", "cannot be used with --color-palette"),
)
}
if rt.Changed("colors") {
colors := normalizedChartColors(rt)
if len(colors) < 2 {
return sheetsValidationForFlag("colors", "--colors must contain at least two hex colors")
}
for _, color := range colors {
if !chartHexColorPattern.MatchString(color) {
return sheetsValidationForFlag("colors", "--colors contains invalid hex color %q", color)
}
}
}
return nil
}
func normalizedChartColors(rt flagView) []string {
raw := rt.StrSlice("colors")
colors := make([]string, len(raw))
for i := range raw {
colors[i] = strings.TrimSpace(raw[i])
}
return colors
}

View File

@@ -0,0 +1,346 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestChartCreateBasic_AllTypes(t *testing.T) {
t.Parallel()
types := []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}
for _, chartType := range types {
chartType := chartType
t.Run(chartType, func(t *testing.T) {
t.Parallel()
rangeValue := "A1:C4"
if chartType == "combo" {
rangeValue = "A1:D4"
}
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", chartType,
"--data-range", rangeValue,
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "create" {
t.Fatalf("operation = %v, want create", input["operation"])
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic create must not send properties")
}
basic, _ := input["basic_chart"].(map[string]interface{})
if basic["chart_type"] != chartType || basic["data_range"] != rangeValue {
t.Fatalf("basic_chart = %#v", basic)
}
})
}
}
func TestChartCreateBasic_ConfigAndPlacement(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", "A1:C4",
"--anchor-cell", "f2",
"--width", "640",
"--height", "360",
"--title", "Trend",
"--legend-position", "bottom",
"--smooth=false",
"--data-direction", "row",
"--color-palette", "brandColorSeries@v2",
})
input := decodeToolInput(t, body, "manage_chart_object")
basic, _ := input["basic_chart"].(map[string]interface{})
position, _ := basic["position"].(map[string]interface{})
size, _ := basic["size"].(map[string]interface{})
if position["col"] != "F" || position["row"] != float64(1) {
t.Errorf("position = %#v, want F2 as zero-based row 1", position)
}
if size["width"] != float64(640) || size["height"] != float64(360) {
t.Errorf("size = %#v", size)
}
if basic["title"] != "Trend" || basic["legend_position"] != "bottom" || basic["smooth"] != false ||
basic["data_direction"] != "row" || basic["color_palette"] != "brandColorSeries@v2" {
t.Errorf("semantic config = %#v", basic)
}
}
func TestChartCreateBasic_MultipleAlignedRanges(t *testing.T) {
t.Parallel()
rangeValue := "'Data, 2026'!A1:A10,'Data, 2026'!K1:L10"
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", rangeValue,
})
input := decodeToolInput(t, body, "manage_chart_object")
basic := input["basic_chart"].(map[string]interface{})
if basic["data_range"] != rangeValue {
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], rangeValue)
}
}
func TestChartCreateBasic_MergesMisalignedOrOverlappingRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{name: "separated rows", input: "'Sheet1'!A1:M1,'Sheet1'!A3:M3", expected: "'Sheet1'!A1:M3"},
{name: "overlapping columns", input: "A1:B10,B1:C10", expected: "A1:C10"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", tt.input,
})
input := decodeToolInput(t, body, "manage_chart_object")
basic := input["basic_chart"].(map[string]interface{})
if basic["data_range"] != tt.expected {
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], tt.expected)
}
})
}
}
func TestChartCreateBasic_RejectsCrossSheetRanges(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", "'A'!A1:A10,'B'!C1:D10",
})
if err == nil {
t.Fatal("expected cross-sheet ranges to fail")
}
}
func TestChartSemanticShortcuts_InBatchUpdate(t *testing.T) {
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales"}},
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"line","data-range":"E1:G10","title":"Trend"}}
]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations len = %d, want 2", len(ops))
}
for i, op := range ops {
item := op.(map[string]interface{})
if item["tool_name"] != "manage_chart_object" {
t.Fatalf("operations[%d].tool_name = %v", i, item["tool_name"])
}
chartInput := item["input"].(map[string]interface{})
if chartInput["operation"] != "create" {
t.Fatalf("operations[%d].input.operation = %v", i, chartInput["operation"])
}
if _, ok := chartInput["basic_chart"].(map[string]interface{}); !ok {
t.Fatalf("operations[%d].input.basic_chart = %#v", i, chartInput["basic_chart"])
}
}
}
func TestChartConfigUpdate_PartialFields(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--y-axis-title", "Revenue",
"--stack", "percent",
"--smooth=false",
"--colors", "#112233,#445566",
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic update must not send properties")
}
updates, _ := input["config_updates"].(map[string]interface{})
if updates["y_axis_title"] != "Revenue" || updates["stack"] != "percent" || updates["smooth"] != false {
t.Errorf("config_updates = %#v", updates)
}
colors, _ := updates["colors"].([]interface{})
if len(colors) != 2 || colors[0] != "#112233" || colors[1] != "#445566" {
t.Errorf("config_updates.colors = %#v", updates["colors"])
}
}
func TestChartConfigUpdate_SpacedSmoothBool(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--smooth", "false",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["config_updates"].(map[string]interface{})
if updates["smooth"] != false {
t.Fatalf("config_updates.smooth = %v, want false", updates["smooth"])
}
}
func TestChartSemanticShortcuts_CompatibleAliases(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--stacked",
})
updates := decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
if updates["stack"] != "normal" {
t.Fatalf("--stacked normalized stack = %v, want normal", updates["stack"])
}
body = parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-labels", "category_percentage",
})
updates = decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
if updates["data_labels"] != "value_percentage" {
t.Fatalf("data-labels normalized value = %v, want value_percentage", updates["data_labels"])
}
}
func TestChartSemanticShortcuts_CompatibleAliasesInBatch(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+chart-config-update","input":{"sheet_id":"sh1","chart_id":"chart-1","stacked":true,"data_labels":"category_percentage","smooth":false}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
chartInput := ops[0].(map[string]interface{})["input"].(map[string]interface{})
updates := chartInput["config_updates"].(map[string]interface{})
if updates["stack"] != "normal" || updates["data_labels"] != "value_percentage" || updates["smooth"] != false {
t.Fatalf("batch config_updates = %#v", updates)
}
}
func TestChartDataUpdate_PreservesSnapshotServerSide(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:M6",
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic data update must not send properties")
}
updates, _ := input["data_updates"].(map[string]interface{})
if updates["data_range"] != "'Sheet1'!A1:M6" {
t.Errorf("data_updates = %#v", updates)
}
if _, ok := updates["data_direction"]; ok {
t.Errorf("omitted --data-direction must preserve the server-side direction: %#v", updates)
}
}
func TestChartDataUpdate_ExplicitDirectionAndMultipleRanges(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:A10,'Sheet1'!K1:L10",
"--data-direction", "column",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["data_updates"].(map[string]interface{})
if updates["data_range"] != "'Sheet1'!A1:A10,'Sheet1'!K1:L10" || updates["data_direction"] != "column" {
t.Errorf("data_updates = %#v", updates)
}
}
func TestChartDataUpdate_ExplicitSeriesIndexes(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:M6",
"--dim1-index", "1",
"--dim2-indexes", "4, 8",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["data_updates"].(map[string]interface{})
if updates["dim1_index"] != float64(1) {
t.Errorf("data_updates.dim1_index = %#v", updates["dim1_index"])
}
indexes, _ := updates["dim2_indexes"].([]interface{})
if len(indexes) != 2 || indexes[0] != float64(4) || indexes[1] != float64(8) {
t.Errorf("data_updates.dim2_indexes = %#v", updates["dim2_indexes"])
}
}
func TestChartSemanticShortcuts_Validation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
}{
{name: "unsupported type", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "donut", "--data-range", "A1:C4"}},
{name: "invalid semantic enum", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--legend-position", "diagonal"}},
{name: "range too small", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:A4"}},
{name: "combo needs two series", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "combo", "--data-range", "A1:B4"}},
{name: "invalid direction", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--data-direction", "horizontal"}},
{name: "colors need two values", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--colors", "#112233"}},
{name: "palette and colors are exclusive", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--color-palette", "brandColorSeries@v2", "--colors", "#112233,#445566"}},
{name: "size must be paired", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--width", "640"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, tt.args)
if err == nil {
t.Fatal("expected validation error")
}
})
}
_, _, err := runShortcutCapturingErr(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
})
if err == nil {
t.Fatal("expected config update with no changed field to fail")
}
for _, args := range [][]string{
{"--url", testURL, "--sheet-id", testSheetID, "--data-range", "A1:C4"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--data-direction", "horizontal"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim1-index", "0"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "2,2"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "1,2"},
} {
_, _, err = runShortcutCapturingErr(t, ChartDataUpdate, args)
if err == nil {
t.Fatalf("expected chart data update validation error for args %#v", args)
}
}
}

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)
},
}
@@ -88,17 +87,19 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
// read cap. Pin cell_limit very high so the tool's own default never binds
// before max_chars.
input["cell_limit"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input
}
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
// tool's two coarse switches:
// tool's switches:
//
// - include_styles (bool) — toggled by "style" presence
// - value_render_option (enum) — "formula" → formula; otherwise omitted
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
// the tool estimate and return per-cell isRowTruncated / isColTruncated
//
// "value", "comment", and "data_validation" are always returned by the tool
// per the schema; they have no dedicated knob today but are accepted in
@@ -119,6 +120,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
if want["formula"] {
input["value_render_option"] = "formula"
}
if want["truncation"] {
input["include_truncation_info"] = true
}
}
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
@@ -165,8 +169,7 @@ var CsvGet = common.Shortcut{
if !runtime.Bool("include-row-prefix") {
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
@@ -183,7 +186,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,22 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
},
},
{
// --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

View File

@@ -128,7 +128,11 @@ 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)
@@ -292,7 +296,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)

View File

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

View File

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

View File

@@ -405,7 +405,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)
@@ -965,7 +969,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 +996,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 +1045,268 @@ 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")
if err != nil {
return nil, err
}
var errsHere []error
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
probs = append(probs, errsHere...)
}
if len(probs) > 0 {
return nil, probs
}
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)
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)}
}
return payload, nil
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
// 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) {
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)
}
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)
}
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
}
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})
ops = append(ops, op)
}
return ops, nil
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"
}
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 == "" {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type is required (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
}
if dimension == "column" && resizeType == "auto" {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
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 workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
}
size = int(n)
}
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"); 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) {
@@ -1259,6 +1360,7 @@ 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
}
@@ -1299,7 +1401,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 {

View File

@@ -48,7 +48,11 @@ var CellsSet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set"),
Validate: validateViaInput(cellsSetInput),
Tips: []string{
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
},
Validate: validateViaInput(cellsSetInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -124,7 +128,11 @@ var CellsSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-style"),
Validate: validateViaInput(cellsSetStyleInput),
Tips: []string{
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
},
Validate: validateViaInput(cellsSetStyleInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)

View File

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

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
@@ -83,7 +84,7 @@ func callTool(
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flattenToolErrorMsg(msg)).
WithCode(int(code))
}
data, _ := envelope["data"].(map[string]interface{})
@@ -100,6 +101,47 @@ func callTool(
return out, nil
}
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
// sheet-ai tools put in msg — batch_update in particular wraps its result as
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
// naming each failed operation. Eval traces show agents (and even the eval
// aggregator) failing to extract the real cause from the double-escaped
// form. Anything that doesn't match the nested shape passes through
// untouched.
func flattenToolErrorMsg(msg string) string {
trimmed := strings.TrimSpace(msg)
if !strings.HasPrefix(trimmed, "{") {
return msg
}
var outer struct {
Error string `json:"error"`
}
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
return msg
}
inner := strings.TrimSpace(outer.Error)
var detail struct {
Message string `json:"message"`
Failures []struct {
Index int `json:"index"`
ToolName string `json:"tool_name"`
Error string `json:"error"`
} `json:"failures"`
}
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
if len(detail.Failures) == 0 {
return detail.Message
}
parts := make([]string, 0, len(detail.Failures))
for _, f := range detail.Failures {
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
}
return detail.Message + " — " + strings.Join(parts, "; ")
}
return inner
}
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
// The wire-format body (with input serialized to a JSON string) is preserved
// for fidelity, and a decoded tool_input map is surfaced alongside so humans

View File

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

View File

@@ -35,6 +35,11 @@ func Shortcuts() []common.Shortcut {
if hasFlag(all[i].Flags, "spreadsheet-token") {
all[i].PostMount = withTokenAlias(all[i].PostMount)
}
// +chart-create grows --print-example (minimal per-type --properties
// templates) — the biggest --print-schema consumer in eval traces.
if all[i].Command == "+chart-create" {
all[i].PostMount = withChartPrintExample(all[i].PostMount)
}
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
// flags inlined, enum vocabulary normalization) ride the same
// PostMount composition, so no other domain's behavior shifts.
@@ -146,6 +151,7 @@ func shortcutList() []common.Shortcut {
// Object CRUD (3 per skill)
ChartCreate, ChartUpdate, ChartDelete,
ChartCreateBasic, ChartConfigUpdate, ChartDataUpdate,
PivotCreate, PivotUpdate, PivotDelete,
CondFormatCreate, CondFormatUpdate, CondFormatDelete,
FilterCreate, FilterUpdate, FilterDelete,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,9 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
# 查询已办审批
lark-cli approval tasks query --params '{"topic":"2"}' --as user
# 按任务时间范围筛选(秒级时间戳)
lark-cli approval tasks query --params '{"topic":"1","start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>"}' --as user
# 使用 page_token 翻页
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
@@ -28,6 +31,8 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
| `topic` | 是 | 任务分组主题见下方“topic 枚举” |
| `definition_code` | 否 | 审批定义 Code用于仅查询某个审批定义下的任务 |
| `start_timestamp` | 否 | 按任务时间筛选,时间范围开始值,秒级时间戳 |
| `end_timestamp` | 否 | 按任务时间筛选,时间范围结束值,秒级时间戳 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
@@ -67,10 +72,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
| `tasks[].summaries` | 表单摘要字段列表 |
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
| `tasks[].user_id` | 任务所属用户 ID |
| `tasks[].instance_external_id` | 三方审批实例 ID仅第三方审批实例存在 |
| `tasks[].task_external_id` | 三方审批任务 ID仅第三方审批任务存在 |
| `tasks[].link` | 三方审批跳转链接 |
## 使用建议
- 常见处理链:先用 `tasks query` 拿到 `task_id``instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`
- 如果你只想看“已发起的审批实例”,使用 `instances initiated``tasks query` 更适合围绕“任务分组”来拉取列表。
- 按时间排查任务时使用 `start_timestamp` / `end_timestamp` 缩小范围;这两个值都是秒级时间戳。
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`
- 当结果量较大时,优先使用 `--format table` 提升可读性。

View File

@@ -23,6 +23,12 @@ lark-cli approval tasks rollback \
--as user \
--yes
# 退回到发起节点(发起节点 ID 为 START
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["START"],"comment":"退回发起人补充材料"}' \
--as user \
--yes
# 传多个候选节点 ID以实际审批定义支持情况为准
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
@@ -43,7 +49,7 @@ lark-cli approval tasks rollback \
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
| `node_ids` | 是 | 退回目标节点 ID 数组;发起节点 ID 为 `START`执行前应先确认这些节点确实可作为退回目标 |
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交``预算说明不完整,请补充` |
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
@@ -75,7 +81,7 @@ lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' -
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组;退回发起节点时传 `START`
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。

View File

@@ -16,15 +16,20 @@
## 2. 各类型 CellValue
### 2.1 text / phone / url
### 2.1 text
用字符串。URL 字段也传 URL 字符串;普通文本里可以保留 Markdown 风格链接文本,平台会按字段类型处理。
text 字段的 `style.type` 影响单元格检查逻辑:
`type=plain` 传 Markdown 格式的字符串。
`type=url` 传一个带 title 的 Markdown 格式链接,或单独传一个链接。
`type=phone` 传合法电话号码。
`type=email` 传合法邮箱字符串。
```json
{
"标题": "Hello",
"标题": "Hello, [lark-cli](https://github.com/larksuite/cli)",
"官网": "[官网](https://example.com)",
"联系电话": "1380000000000",
"官网": "https://example.com"
"邮箱": "owner@example.com"
}
```

View File

@@ -23,12 +23,12 @@ lark-cli base +field-create \
lark-cli base +field-create \
--base-token <base_token> \
--table-id <table_id> \
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
--json '{"name":"状态","type":"select","multiple":false,"default_value":["Todo"],"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Done","hue":"Green","lightness":"Light"}]}'
lark-cli base +field-create \
--base-token <base_token> \
--table-id <table_id> \
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
--json '{"name":"负责人","type":"user","multiple":false,"default_value":[{"$slot":"current_user"}],"description":"用于标记记录的直接负责人;协作约定可参考[团队字段约定](https://example.com/field-spec)"}'
```
## 参数
@@ -51,6 +51,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义,不要再套一层。
- 顶层最少包含:`name``type`
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接,如 `协作约定可参考[团队字段约定](https://example.com/field-spec)`
- 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue`datetime` / `user` 的动态填充用 `$slot`。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。
- `type` 不同,必填子字段不同:
- `select``multiple` 控制是否多选,`options` 定义静态选项,`dynamic_options_source` 定义动态选项来源。静态与动态选项配置二选一,不能同时传。
- `link`:必须有 `link_table`,可选 `bidirectional``bidirectional_link_field_name`
@@ -64,6 +65,7 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
"name": "状态",
"type": "select",
"multiple": false,
"default_value": ["Todo"],
"options": [
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
{ "name": "Done", "hue": "Green", "lightness": "Light" }

View File

@@ -9,6 +9,7 @@
- `--json` 必须是 JSON 对象。
- 顶层统一使用:`type` + `name` + 类型特有字段。
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
- 字段默认值使用 `default_value`,直接传对应 CellValue支持范围只有 `text``number`、静态 `select``datetime``user`。清空默认值传 `null`;省略表示创建时不设置、更新时不修改。
- 不要使用旧结构:`field_name``property``ui_type`、数字枚举 `type`
- `+field-update` 使用同样的字段 JSON 结构,但语义是 `PUT`;这是高风险写入操作,建议先 `+field-get` 再按目标状态全量提交,并带 `--yes`
- `type=formula``type=lookup` 创建/更新前,必须先读对应 guide。
@@ -27,12 +28,12 @@
| 类型 | 最小必填字段 | 常见补充字段 |
|------|--------------|-------------|
| `text` | `type` `name` | `style.type` |
| `number` | `type` `name` | `style` |
| `select` | `type` `name` | `multiple` + `options`,或 `multiple` + `dynamic_options_source` |
| `datetime` | `type` `name` | `style.format` |
| `text` | `type` `name` | `style.type` `default_value` |
| `number` | `type` `name` | `style` `default_value` |
| `select` | `type` `name` | `multiple` + `options` + 静态 `default_value`,或 `multiple` + `dynamic_options_source` |
| `datetime` | `type` `name` | `style.format` `default_value` |
| `created_at` / `updated_at` | `type` `name` | `style.format` |
| `user` / `group_chat` | `type` `name` | `multiple` |
| `user` / `group_chat` | `type` `name` | `multiple`;仅 `user` 支持 `default_value` |
| `created_by` / `updated_by` | `type` `name` | 无 |
| `link` | `type` `name` `link_table` | `bidirectional` `bidirectional_link_field_name` |
| `formula` | `type` `name` `expression` | 无 |
@@ -47,31 +48,37 @@
### 3.1 text
文本字段;电话、超链接、邮箱、条码也都属于 `text`,通过 `style.type` 区分。
支持 `default_value`:静态 Markdown 文本字符串;`phone` style 必须是合法电话号码;`url` style 传一个 Markdown 链接或裸 URL`email` style 必须是合法邮箱字符串,不要传 Markdown 链接或 `mailto:`
最小写法(默认 `style.type``plain`
```json
{
"type": "text",
"name": "标题"
"name": "标题",
"default_value": "默认标题"
}
```
常用写法:
默认值可以是 Markdown 文本
```json
{
"type": "text",
"name": "标题",
"description": "主标题字段"
"description": "主标题字段",
"default_value": "未命名"
}
```
`style.type=phone` 时默认值是合法电话号码字符串。
```json
{
"type": "text",
"name": "联系电话",
"style": { "type": "phone" }
"style": { "type": "phone" },
"default_value": "+8613800000000"
}
```
@@ -79,7 +86,17 @@
{
"type": "text",
"name": "官网",
"style": { "type": "url" }
"style": { "type": "url" },
"default_value": "[官网](https://example.com)"
}
```
```json
{
"type": "text",
"name": "邮箱",
"style": { "type": "email" },
"default_value": "owner@example.com"
}
```
@@ -88,13 +105,15 @@
### 3.2 number
数字字段;货币、进度、评分都属于 `number`,通过 `style.type` 区分。
支持 `default_value`:静态 JSON number所有 number style 都按这个规则写。
最小写法(默认 `style.type``plain`
```json
{
"type": "number",
"name": "工时"
"name": "工时",
"default_value": 8
}
```
@@ -118,7 +137,8 @@
"precision": 2,
"percentage": false,
"thousands_separator": true
}
},
"default_value": 8
}
```
@@ -151,7 +171,8 @@
{
"type": "number",
"name": "完成度",
"style": { "type": "progress", "percentage": true, "color": "Blue" }
"style": { "type": "progress", "percentage": true, "color": "Blue" },
"default_value": 0.65
}
```
@@ -180,6 +201,7 @@
#### 静态选项
支持字段:`multiple``options`
支持 `default_value`:静态选项名数组;即使 `multiple=false` 也写数组,如 `["Todo"]`
默认值 / 约束:
- `multiple` 默认 `false`
@@ -189,12 +211,14 @@
- `options[].hue` 可用:`Red``Orange``Yellow``Lime``Green``Turquoise``Wathet``Blue``Carmine``Purple``Gray` 缺省值为 `Blue`
- `options[].lightness` 可用:`Lighter``Light``Standard``Dark``Darker` 缺省值为 `Lighter`
- 选项里没有 `id`,只有 `name`
- 支持 `default_value` 配置:填选项名数组。
```json
{
"type": "select",
"name": "状态",
"multiple": false,
"default_value": ["Todo"],
"options": [
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
{ "name": "Done", "hue": "Green", "lightness": "Light" }
@@ -205,6 +229,7 @@
#### 动态选项
支持字段:`multiple``dynamic_options_source`
动态选项不支持 `default_value`
默认值 / 约束:
- `multiple` 默认 `false`
@@ -213,6 +238,7 @@
- `dynamic_options_source.field_id` 填来源字段 id 或字段名
- `dynamic_options_source` 仅创建支持;更新已有字段时不要传
- 引用选项条件 / 级联筛选条件:这个功能在 Base 前端支持,属于 UI-only 属性OpenAPI 里不支持CLI 不能读取、创建或更新;不要根据接口返回缺失判断未配置
- 动态选项不支持配置 `default_value`
```json
{
@@ -229,13 +255,15 @@
### 3.4 datetime
手动填写的日期/时间字段。系统时间用 `created_at` / `updated_at`
支持 `default_value`:静态时间字符串,或 `{ "$slot": "record_created_time" }``datetime + record_created_time` 是自动填充可编辑单元格;`created_at` 是只读创建时间元信息。
最小写法:
```json
{
"type": "datetime",
"name": "截止时间"
"name": "截止时间",
"default_value": "2026-03-24 10:00:00"
}
```
@@ -251,7 +279,8 @@
{
"type": "datetime",
"name": "截止时间",
"style": { "format": "yyyy-MM-dd HH:mm" }
"style": { "format": "yyyy-MM-dd HH:mm" },
"default_value": { "$slot": "record_created_time" }
}
```
@@ -276,12 +305,19 @@
### 3.6 user / group_chat
人员字段和群字段都支持 `multiple`
`user` 支持 `default_value`:人员 CellValue 数组,元素可用 `{ "id": "ou_xxx" }``{ "$slot": "current_user" }`;不要猜用户 ID。`group_chat` 不支持默认值。
默认值 / 约束:
- `multiple` 默认 `true`
- `user` 字段支持 `default_value` 配置,`group_chat` 字段不支持 `default_value` 配置。
```json
{ "type": "user", "name": "负责人", "multiple": true }
{
"type": "user",
"name": "负责人",
"multiple": true,
"default_value": [{ "$slot": "current_user" }, { "id": "ou_xxx" }]
}
```
```json
@@ -488,3 +524,4 @@ Object对象字段、Button按钮字段、Stage流程字段
- `number` 的精度、货币、进度、评分配置都放在 `style` 下,不要写顶层 `precision`
- `datetime` 是手动日期字段;系统时间请改用 `created_at` / `updated_at`
- `formula` / `lookup` 没读 guide 前不要直接写。
- 只有 `text``number`、静态 `select``datetime``user` 支持 `default_value`;清空统一传 `"default_value": null`。其他字段类型不要配置默认值。

View File

@@ -11,14 +11,14 @@ lark-cli base +field-update \
--base-token <base_token> \
--table-id <table_id> \
--field-id <field_id> \
--json '{"name":"状态","type":"select","multiple":false,"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \
--json '{"name":"状态","type":"select","multiple":false,"default_value":["Doing"],"options":[{"name":"Todo","hue":"Blue","lightness":"Lighter"},{"name":"Doing","hue":"Orange","lightness":"Light"},{"name":"Done","hue":"Green","lightness":"Light"}]}' \
--yes
lark-cli base +field-update \
--base-token <base_token> \
--table-id <table_id> \
--field-id <field_id> \
--json '{"name":"负责人","type":"user","multiple":false,"description":"用于标记记录的直接负责人"}' \
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
--yes
```
@@ -47,6 +47,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
- 更新语义是 `PUT`(全量字段配置更新),不要只传零散片段;至少显式包含 `name``type`,并补齐该类型所需关键配置。
- 所有字段类型都支持可选 `description`;支持纯文本,也支持 Markdown 链接。
- 需要字段默认值时传 `default_value`,直接使用字段对应 CellValue`null` 清空,省略表示不修改现有默认值。完整规则见 [lark-base-field-json.md](lark-base-field-json.md)。
- `select` 更新时:`options` 仍按对象数组传,避免混入无效字段。
- `link` 更新限制:
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`
@@ -59,6 +60,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
"name": "状态",
"type": "select",
"multiple": false,
"default_value": ["Doing"],
"options": [
{ "name": "Todo", "hue": "Blue", "lightness": "Lighter" },
{ "name": "Doing", "hue": "Orange", "lightness": "Light" },

View File

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

View File

@@ -0,0 +1,170 @@
# Approval Events
> **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage).
## Key catalog (2)
| EventKey | Purpose |
|---|---|
| `approval.instance.status_changed_v4` | An approval instance status changed |
| `approval.task.status_changed_v4` | An approval task status changed |
Both keys use a **Custom schema**. The raw Lark schema 2.0 envelope is flattened: event metadata is exposed as `type`, `event_id`, and `timestamp`, while approval business fields are exposed at the top level.
Both keys carry a **PreConsume hook** that subscribes the current authorized user through the Approval subscription APIs before listening. The consumer intentionally does **not** unsubscribe on exit; the server-side Approval subscription relation remains until it is canceled outside `event consume`. These keys require `--as user`.
## Listener and subscription selection
At the raw CLI level, each `event consume` process accepts exactly one EventKey. `approval.instance.status_changed_v4` and `approval.task.status_changed_v4` have different output shapes, so listening to both still means two processes.
For Approval only, `subscription_type` is an optional setup param used by PreConsume to register server-side Approval subscription relations before the local listener starts. It is **not** an output field, a local event filter, or a local subscription identity. The pushed event does not say which subscription relation caused delivery, and one business event can match both relations; deduplicate with `event_id` when needed.
`subscription_type` may be omitted, a single value, a comma-separated list, or a JSON string array:
```bash
# Omitted: register both INVOLVED_APPROVAL and MANAGED_APPROVAL for this EventKey
lark-cli event consume approval.instance.status_changed_v4 --as user
# Single relation
lark-cli event consume approval.instance.status_changed_v4 \
-p subscription_type=INVOLVED_APPROVAL \
--as user
# Explicit multi-relation registration for one local consumer
lark-cli event consume approval.task.status_changed_v4 \
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
--as user
# JSON array form; quote it for the shell
lark-cli event consume approval.task.status_changed_v4 \
-p 'subscription_type=["INVOLVED_APPROVAL","MANAGED_APPROVAL"]' \
--as user
```
| Value | Meaning |
|---|---|
| `INVOLVED_APPROVAL` | Receive events where the current user is the approval requester or approver |
| `MANAGED_APPROVAL` | Receive events under approval definitions managed by the current user |
User-intent inference:
| User intent | EventKey(s) | `subscription_type` |
|---|---|---|
| Mentions approval instances, approval forms, approval order/status, or "instance status" | `approval.instance.status_changed_v4` | infer from relation words below |
| Mentions approval tasks, approval todo items, approver operations, or "task status" | `approval.task.status_changed_v4` | infer from relation words below |
| Says "approval status changes/events" without saying task vs instance | both EventKeys | infer from relation words below |
| Says "my approvals", "approvals involving me", "I requested/approved", "待我审批", "我发起/我参与" | requested EventKey(s) | `INVOLVED_APPROVAL` |
| Says "approvals I manage", "managed definitions", "definitions managed by me", "我管理的审批定义" | requested EventKey(s) | `MANAGED_APPROVAL` |
| Explicitly asks for both involved and managed, or says "all approval subscriptions" | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type`, or pass both values in one `-p` |
| Relation is ambiguous and the user wants broad coverage | requested EventKey(s), or both if EventKey is also ambiguous | omit `subscription_type` so PreConsume registers both |
If the user's wording omits the relation and broad listening is acceptable, omit `subscription_type`. Ask only when registering both relations would be materially harmful.
## Scopes & auth
| EventKey | Scope | Auth |
|---|---|---|
| `approval.instance.status_changed_v4` | `approval:instance:read` | user |
| `approval.task.status_changed_v4` | `approval:task:read` | user |
## Subscription behavior
Startup calls the endpoint for the selected EventKey:
```text
POST /open-apis/approval/v4/instances/subscription
POST /open-apis/approval/v4/tasks/subscription
```
For each resolved `subscription_type`, PreConsume sends one request body:
```json
{"subscription_type":"INVOLVED_APPROVAL"}
```
If `subscription_type` is omitted, PreConsume sends two registration requests for that EventKey: one with `INVOLVED_APPROVAL`, then one with `MANAGED_APPROVAL`. If listening to both instance and task events, run two consumers; each consumer may omit `subscription_type` to register both relations for its own EventKey.
Do not start two consumers for the same Approval EventKey merely to split `INVOLVED_APPROVAL` and `MANAGED_APPROVAL`. The server push and flattened output are keyed by EventKey and cannot be distinguished by subscription relation.
Shutdown behavior:
`event consume` does not call the Approval unsubscribe APIs when it exits. This applies to graceful exit, Ctrl+C / SIGTERM, stdin EOF, `--timeout`, and `--max-events`.
To stop future delivery for a user, cancel the Approval subscription relation outside this consumer. The unsubscribe APIs are separate operations and are not called by `event consume`.
## Output fields
Common fields:
| Field | Type | Description |
|---|---|---|
| `type` | string | Event type |
| `event_id` | string | Globally unique event ID; use for deduplication |
| `timestamp` | string (timestamp_ms) | Event delivery time in milliseconds, taken from `header.create_time` |
Instance event fields:
| Field | Type | Description |
|---|---|---|
| `approval_code` | string | Approval definition code; not a subscription dimension |
| `instance_code` | string | Approval instance code |
| `external_id` | string | Third-party approval instance id, when present |
| `status` | string enum | `PENDING`, `APPROVED`, `REJECTED`, `CANCELED`, `DELETED`, `REVERTED`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
| `operate_time` | string (timestamp_ms) | Status change time |
| `start_user` | object | Instance starter user IDs, omitted when unavailable |
| `start_user.open_id` | string (open_id) | Instance starter open_id, when present |
| `start_user.union_id` | string (union_id) | Instance starter union_id, when present |
| `start_user.user_id` | string (user_id) | Instance starter tenant user_id, when present |
Task event fields:
| Field | Type | Description |
|---|---|---|
| `approval_code` | string | Approval definition code; not a subscription dimension |
| `instance_code` | string | Approval instance code |
| `task_id` | string | Approval task id |
| `external_id` | string | Third-party approval external id, when present |
| `task_external_id` | string | Third-party task external id, when emitted |
| `assigned_user` | object | Task assignee or operator user IDs, omitted for automatic flows without an operator |
| `assigned_user.open_id` | string (open_id) | Task assignee or operator open_id, when present |
| `assigned_user.union_id` | string (union_id) | Task assignee or operator union_id, when present |
| `assigned_user.user_id` | string (user_id) | Task assignee or operator tenant user_id, when present |
| `status` | string enum | `REVERTED`, `PENDING`, `APPROVED`, `REJECTED`, `TRANSFERRED`, `ROLLBACK`, `DONE`, `OVERTIME_CLOSE`, `OVERTIME_RECOVER` |
| `operate_time` | string (timestamp_ms) | Status change time |
## Examples
```bash
# Stream approval instance updates broadly; registers both involved and managed relations
lark-cli event consume approval.instance.status_changed_v4 \
--as user
# Stream approval instance updates only for approvals involving the current user
lark-cli event consume approval.instance.status_changed_v4 \
-p subscription_type=INVOLVED_APPROVAL \
--as user
# Stream approval task updates for definitions managed by the current user
lark-cli event consume approval.task.status_changed_v4 \
-p subscription_type=MANAGED_APPROVAL \
--as user
# Broad approval status listening:
# run both EventKeys as separate processes; omit subscription_type so each registers both relations.
lark-cli event consume approval.instance.status_changed_v4 \
--as user > approval-instance.ndjson &
lark-cli event consume approval.task.status_changed_v4 \
--as user > approval-task.ndjson &
wait
# Listen to both involved and managed task subscriptions with one local consumer.
lark-cli event consume approval.task.status_changed_v4 \
-p subscription_type=INVOLVED_APPROVAL,MANAGED_APPROVAL \
--as user > approval-task.ndjson
# Project a compact approval-task record
lark-cli event consume approval.task.status_changed_v4 \
-p subscription_type=INVOLVED_APPROVAL \
--as user \
--jq '{event_id, task_id, status, at: .operate_time}'
```

View File

@@ -15,13 +15,7 @@ metadata:
## 术语约定
下列词在本 skill 各文档中可能交替出现,但**指同一对象**;解析用户口语时按此映射,不要当成不同概念:
| 标准用语 | 同义 / 口语(均指同一对象) | 说明 |
| --- | --- | --- |
| 工作表sheet | 子表、tab、标签页 | spreadsheet 内的单张表;`sheet_id` 是其稳定标识 |
| 电子表格spreadsheet | 工作簿、表格 | 顶层容器;由 `--url``--spreadsheet-token` 定位 |
| reference_id | id | **表内对象**的稳定标识,即各对象主键 flag 接受的值(见下表)。⚠️ 与 `lark-sheets-float-image``--image-uri`(图片上传句柄)不是一回事,后者不属于 reference_id |
同一对象的交替说法,按此映射解析用户口语:**工作表sheet**= 子表 / tab / 标签页(`sheet_id` 是稳定标识);**电子表格spreadsheet**= 工作簿 / 表格(顶层容器,由 `--url``--spreadsheet-token` 定位);**reference_id** = 表内对象的稳定标识,即各对象主键 flag 接受的值(与 `--image-uri` 图片上传句柄不是一回事)。
每类对象用各自的主键 flag 定位(命名不统一,按此表对照,不要凭直觉拼):
@@ -34,30 +28,30 @@ metadata:
## 飞书表格编辑准则(动手前必守,所有编辑类任务一律生效)
下列准则横切所有飞书表格任务,**动手前先过一遍**——即使你是被索引直接路由进某个工具参考也一律生效。每条只给一句话纲要,展开与边界见括注的 reference。
下列准则横切所有任务,**动手前先过一遍**——被索引直接路由进某个工具参考也一律生效展开与边界见括注的 reference。
1. **最小改动**:除任务要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式 1:1 保持;中间结果放原数据右侧或新建空白 Sheet**禁止删 / 改名 / 隐藏 / 移动已存在 Sheet**;改写类任务精确圈定行列,不该转的原值 1:1 保留。
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,写完用 `+csv-get` / `+cells-get` / `+<对象>-list` 回读确认实际生效——**写操作返回 `ok` 只代表请求被接受、不代表结果符合预期**;写公式后查错误码、筛选 / 排序后核对前几行、删除 / 清空后确认已空。禁止只在文本里声称"已完成"。
3. **读全再写**:批量填充 / 补齐 / 修正类任务先确认真实数据末行再写,只探前 N 行会漏写表尾(确定末行流程见 `lark-sheets-read-data`)。
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找)一律写公式而非静态值**凡可由表内其它单元格推导的派生值默认用公式,即使用户没说"联动 / 自动更新"**;写任何飞书公式前先读 `lark-sheets-formula-translation`而且**只要公式真实写入表格,收尾默认就要继续跑 `lark-sheets-formula-verify``+formula-verify`直到 `status='success'`**。
4. **公式优先于硬编码**:能用公式表达的计算(总计 / 占比 / 提取 / 查找)一律写公式而非静态值——**凡可由表内其它单元格推导的派生值默认用公式,即使用户没说"联动"**;写公式前先读 `lark-sheets-formula-translation`**公式落表后收尾必跑 `+formula-verify` 直到 `status='success'`**。
5. **续写 / 扩展继承样式**:续写、补齐、复制区块、新增行列时禁止只读值只写值,必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承(清单见 `lark-sheets-write-cells`,四边框最易漏)。
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`(语义见 `lark-sheets-batch-update`)。
6. **多步写入合并 `+batch-update`**:多个连续写入、或同一工具对多区域重复调用,合并为单次原子 `+batch-update`high-risk-write**调用必带 `--yes`**语义见 `lark-sheets-batch-update`)。
7. **分组汇总用透视表**"按 X 统计 Y / 分组汇总 / 各类数量金额"用 `+pivot-{create|update|delete}`,禁止用 SUMIF / 本地脚本拼一张假透视表。
8. **拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",逐点 `assert` 全过才交付(多维排序每维一点、多目标每目标一点、范围类核起 / 末 / 边界);只做第一个要点属违规。
9. **全量处理前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,先把预期条数硬编码再 `assert actual == expected`,禁止输出"已完成前 N 条,剩余继续"的半成品。
> 上述准则的实操展开——读取路径、原生工具优先级、脚本配合、易漏陷阱——见下方「执行要点」节;端到端工作流:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
> 端到端工作流:了解结构(`+workbook-info`)→ 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证;实操展开见下方「执行要点」
## 场景 → 命令速查(拿不准命令名先查这里,别按直觉拼)
把高频意图映射到**真实存在**的 shortcut / flagagent 常从 Excel / Google Sheets / 飞书 OpenAPI 误迁移命令名或 flag先对照本表避免一次必然失败的试错。完整 shortcut 见各工具参考。**选定命令后别急着写——先读「动手前读」列指向的 reference 再动手**命令名对得上不代表用法对,写入 / 清除 / 透视类尤其容易漏掉 reference 里的防错、类型与样式继承规则
把高频意图映射到**真实存在**的 shortcut / flagagent 常从 Excel / Google Sheets / OpenAPI 误迁移命令名。**选定命令后先读「动手前读」列指向的 reference 再动手**——命令名对得上不代表用法对。
| 你要做的事 | ✅ 正确写法 | 动手前读 | ❌ 不存在(会被 cobra 拒) |
| --- | --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | `lark-sheets-read-data` | `+get-range``+range-get``+cells-read` |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `lark-sheets-read-data` | `+get-cell``+cell-get``--with-styles``--with-merges``--include-merged-cells` |
| 写纯文本值(整块 CSV 平铺;列里**没有**需字面保真的数值 / 日期标签 / 编号——点分日期 `12.10`、编号 `001` 会被 csv-put 数值化,不算纯文本 | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格也接受 `--range` 别名,区间自动取左上角 | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10``12.1``001``1`,尾零/前导零丢失),改用 `+table-put` 声明 `dtypes:object` |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数等**本质是量值**的数据——不看当下要不要排序 / 求和,量值一律走这里) | `+table-put --sheets` 完整 payload `{"sheets":[{...}]}`(列名走 `columns`、二维数据走 `data`、列 pandas dtype 走 `dtypes`、列展示格式走 `formats`;来源不限 DataFrame——Counter / dict / list 同理;要同时美化加 `--styles` 一步带样式(区域底色 / 边框 / 列宽 / 行高 / 合并不必事后再刷payload 里不存在的 sheet 名会自动建子表,详见 write-cells | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`落成文本、丢计算能力;常见借口见下方 ⚠️) |
| 写纯文本值(整块 CSV 平铺;列里没有需字面保真的编号 / 点分日期 | `+csv-put`(定位用 `--start-cell` 左上角锚点格也接受 `--range` 别名) | `lark-sheets-write-cells` | 把含点分日期(`12.10`)/编号(`001`)的列裸灌 `+csv-put`——会被数值化(`12.10``12.1``001``1`),改用 `+table-put` 声明 `dtypes:object` |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期等**量值**——不看当下要不要排序求和,量值一律走这里) | `+table-put --sheets '{"sheets":[{"name":…,"columns":[…],"dtypes":{…},"formats":{…},"data":[[…]]}]}'`(不存在的 sheet 名自动建子表;同时美化加 `--styles` 一步带样式,详见 write-cells | `lark-sheets-write-cells` | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(落成文本、丢计算能力见下方 ⚠️) |
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`date / number 不丢;`--styles` 同样可在建表同一步带全套样式,详见 workbook | `lark-sheets-workbook` | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
| 写公式 / 富写入(样式 · 批注 · 图片 · 富文本),或需精确矩形定位的值 | `+cells-set`(定位用 `--range`;批注 / 图片 / 富文本只能用它,公式也可;**公式落表后继续 `+formula-verify` 收尾** | `lark-sheets-write-cells` | — |
| 插图:图片**绑定到某条记录**、随行走(凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图) | `+cells-set-image`(单格 `--range`,嵌入单元格内) | `lark-sheets-write-cells` | — |
@@ -68,37 +62,50 @@ metadata:
| 复核某次AI编辑改了什么 / 取两个版本间的变更 | `+changeset-get --start-revision <编辑前版本>`(省略 `--end-revision` 取到最新;版本差 ≤ 20 | `lark-sheets-changeset` | — |
| 取当前文档 revision版本号 | `+revision-get` | `lark-sheets-workbook` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | `lark-sheets-workbook` | — |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`本地表格文件 → 飞书电子表格的正解;仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`导电子表格时绕了 drive 通道、还要多给 `--type`,应直接用 `+workbook-import`)、把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(多此一举,应直接 `+workbook-import`)、要把文件并入某个**已有在线工作簿**(给它加子表)却用它——import 只会新建独立表,加子表`+sheet-copy` / `+sheet-create` |
| 参考某个**已有在线表**、把多个本地文件 / 数据各作为一张子表**追加**进去(不另起独立表) | 先 `+workbook-info` 拿模板子表 `sheet_id``+sheet-copy` 逐张复制模板子表(公式 / 合并 / 分组底色 / 列宽 / 条件格式全继承)再 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` 建空子表 + `+table-put --sheets/--styles` 写入 | `lark-sheets-workbook` | 把文件 `+workbook-import` / `+workbook-create` 另起一张**独立新表**(目标是并入已有工作簿时就跑偏了;这两条只产新表、不接受已有表定位) |
| 清除内容 / 格式 | `+cells-clear`范围维度用 `--scope`,取值 content / formats / all | `lark-sheets-range-operations` | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `lark-sheets-batch-update` | `--target` |
| 导入本地 xlsx/xls/csv 文件为飞书电子表格 | `+workbook-import --file ./x.xlsx`(仅要导成多维表格 bitable 时才用 `drive +import --type bitable` | `lark-sheets-workbook` | `drive +import`绕路)、本地读 .xlsx 再 `+workbook-create` 重灌(多此一举)、想并入**已有工作簿**却用它import 只会另起新表,加子表走 `+sheet-copy` / `+sheet-create` |
| 参考某个**已有在线表**、把多数据各作为一张子表**追加**进去 | 先 `+workbook-info``+sheet-copy` 复制模板子表(公式 / 合并 / 底色 / 列宽全继承)再 `+cells-*` 只改数据;无模板可继承时 `+sheet-create` + `+table-put --sheets/--styles` | `lark-sheets-workbook` | `+workbook-import` / `+workbook-create` 另起独立新表(这两条只产新表、不接受已有表定位) |
| 清除内容 / 格式 | `+cells-clear --yes`(需确认;范围维度用 `--scope`,取值 content / formats / all | `lark-sheets-range-operations` | `--type` |
| 批量清除多区域 | `+cells-batch-clear --yes`(需确认;`--scope` | `lark-sheets-batch-update` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `lark-sheets-range-operations` | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | `lark-sheets-pivot-table` | 用 SUMIF / 本地脚本拼一张假透视表 |
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | `+chart-create` | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
| 画图表 / 可视化(柱 / 折线 / 饼 / 条 / 散点 / 组合…) | 先读 `lark-sheets-chart`;普通图用 `+chart-create-basic`,多图用一次 `+batch-update --continue-on-error`,已有图的数据源用 `+chart-data-update`、常用配置用 `+chart-config-update`;只有单系列 / 单数据点 / 高级引擎字段才用完整 `+chart-create` / `+chart-update` snapshot | `lark-sheets-chart` | matplotlib / 本地画图再贴图(原生图表可交互、随数据更新) |
| 条件高亮 / 数据条 / 色阶 / 重复值标记 | `+cond-format-create` | `lark-sheets-conditional-format` | `+highlight``+conditional-format`、逐格 `+cells-set-style` 硬凑 |
| 筛选 / 只看符合条件的行 | `+filter-create` | `lark-sheets-filter` | pandas filter 后覆盖写回(会毁原数据;要保存多份筛选状态用 `+filter-view-create` |
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**本次操作只要**涉及样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 汇总行 / 配色 / 列宽行高),动手前先读 `lark-sheets-visual-standards`只要**要写飞书公式**,动手前先读 `lark-sheets-formula-translation`(飞书函数与 Excel 有差异,凭直觉迁移易错),**写完后再读 `lark-sheets-formula-verify` 并执行 `+formula-verify` 收尾**。哪怕主任务是"建表 / 展开数据 / 录入",只要动作里含美化或写公式就适用——别因"这不算专门的美化 / 公式任务"而跳过
> ⚠️ **两种图片别选错**:图**绑定某条记录、随行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ 单元格图片 `+cells-set-image`只是自由摆放的装饰logo / 水印 / 封面)→ 浮动图片 `+float-image-create`。别因「浮动图更好控制 / 更熟」默认选浮动图。
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 比率 / 计数 / 日期等**本质是量值**的数据 → 一律数值写入常规二维表用 `+table-put``dtypes` 声明类型 + `formats` 设展示格式),版式装不下(多级 / 合并表头的宽表 leaderboard 等)改用 `+cells-set` 传数字(百分比传小数 `0.4`+ `number_format`,照样显示 `40%` 且数值无损。只有编号 / 身份证 / 单据号这类**本质是标识符**、要字面保真的才用 `+csv-put` 平铺。**几个常见借口都不成立**——"只是 leaderboard / 报表展示不用算""版式复杂""样式以后再刷、先铺文本"都不是把百分比写成 `"40%"` 字符串灌 `+csv-put` 的理由(展示不改变它是数值;类型不能后补,落成文本就回不来)。判据与操作展开见 `lark-sheets-write-cells`「数字还是文本」。
> ⚠️ **要新建子表 / 整表美化 → 别默认「`+csv-put` 写值再事后刷样式」**`+table-put` / `+workbook-create` 的 `--styles` 在写数据**同一步**带全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并),且 `+table-put` 的 payload 里 sheet 名不在工作簿中会自动建子表——**纯文本表要新建子表 + 美化时同样走这里**`--styles` 与列是否 typed 无关),比「`+csv-put` 写值 + 多次 `+cells-batch-set-style` / `+*-resize` 刷样式少好几次调用(冻结行列等 sheet 级属性仍需 `+dim-freeze` 单独一步)。
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 规范用 `--start-cell`单个左上角锚点格),也接受 `--range` 别名区间自动取左上角),二者择一即可
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`**没有** `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`,不要在 `+cells-get` 里找 merge flag
> ⚠️ **动手前的触发式必读(按动作判定,不看主场景)**动作里**含样式 / 美化**(底色 / 边框 / 字号 / 对齐 / 数字格式 / 配色 / 列宽行高)先读 `lark-sheets-visual-standards`**要写飞书公式**先读 `lark-sheets-formula-translation`,写完跑 `+formula-verify` 收尾(见 `lark-sheets-formula-verify`)。主任务是建表 / 录入也一样适用
> ⚠️ **两种图片别选错**:图**绑定某条记录、随行**(凭证 / 证件照 / 每行配图)→ `+cells-set-image`自由摆放的装饰logo / 水印 / 封面)→ `+float-image-create`。别因「浮动图更熟」默认选浮动图。
> ⚠️ **纯文本还是数值语义(看数据本质,不看当下用途)**:金额 / 百分比 / 日期 / 计数等**量值**一律数值写入——常规二维表用 `+table-put``dtypes` + `formats`),宽表 / 合并表头版式用 `+cells-set` 传数字(百分比传小数 `0.4`+ `number_format`。只有编号 / 身份证等**标识符**才 `+csv-put` 平铺。"只是展示不用算 / 样式以后再刷"不构成把量值写成字符串的理由——类型不能后补。判据见 `lark-sheets-write-cells`「数字还是文本」。
> ⚠️ **要新建子表 / 整表美化 → 别「`+csv-put` 写值再事后刷样式」**`+table-put` / `+workbook-create` 的 `--styles` 在写数据**同一步**带全套样式(底色 / 边框 / 列宽行高 / 合并payload 里不存在的 sheet 名自动建子表,纯文本表同样适用;比事后多次刷样式少好几次调用(冻结行列仍需 `+dim-freeze` 单独一步)。
> ⚠️ **定位 flag**`+cells-get` / `+cells-set` / `+csv-get` 用 `--range``+csv-put` 用 `--start-cell`(也接受 `--range` 别名区间取左上角)。
> ⚠️ **读取附加信息**一律走 `+cells-get --include …`(无 `--with-styles` 这类 flag**看合并单元格**用 `+sheet-info` 的 `merged_cells`。
💡 **高频写命令签名(照抄改参即可;各命令 `--help` 的 Tips 段有同款示例)**
```bash
lark-cli sheets +cells-set --url <U> --sheet-name S1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]' # --cells 恒为二维数组 [[…]],单格也是 [[{…}]]
lark-cli sheets +cells-set-style --url <U> --sheet-name S1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center
lark-cli sheets +cells-batch-set-style --url <U> --ranges '["S1!A1:B2","汇总!C1:C9"]' --font-weight bold # range 带表名前缀,无 sheet 定位 flag
lark-cli sheets +batch-update --url <U> --yes --operations - <<'JSON'
[{"shortcut":"+cells-set","input":{"sheet_name":"S1","range":"A1","cells":[[{"value":"x"}]]}}]
JSON
lark-cli sheets +dim-freeze --url <U> --sheet-name S1 --dimension row --count 2
lark-cli sheets +dim-insert --url <U> --sheet-name S1 --position 3 --count 2 --inherit-style before # 行/列由 --position 决定:数字=行、字母=列,无 --dimension
lark-cli sheets +cols-resize --url <U> --sheet-name S1 --range A:C --width 120 # 像素;分列不同宽用 --widths '{"A":80,"C:E":120}'
lark-cli sheets +sheet-copy --url <U> --sheet-name 源表名 --title 副本名 # --sheet-name=源表、--title=新表名
```
## 执行要点(读取 / 原生工具 / 陷阱)
准则的实操展开。端到端工作流:了解结构 → 读数据 → 理解语义 → 原生工具优先 → 写入 → 回读验证。
### 读取:按需求选路径(细则见 `lark-sheets-read-data`
| 用户需求 | 读取路径 |
|---|---|
| "完善 / 补齐 / 填空 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行,不以用户选区为准 |
| "查一下 / 看看 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
| "完善 / 补齐 / 修正所有 XX"、分析 / 清洗 / 大数据 | 原生优先(公式 / `+pivot` / `+filter`);表达不了再分批 `+csv-get` 导出 + 脚本处理 + 分批回写(默认覆盖所有对应数据行) |
| "查一下 / 统计 / 汇总"等只读 | `+csv-get` 读到上下文 |
| 需要公式 / 样式 / 批注 | `+cells-get` |
| 续写 / 扩展已有内容 | `+csv-get` 看结构 + `+cells-get` 读源区样式 + `+sheet-info --include row_heights,merges`(见准则 5 |
> "补齐 / 填空"类用只读路径探 10 行就写会漏写表尾——写入前先按 `lark-sheets-read-data` 确认真实数据末行(准则 3
> "补齐 / 填空"类只探前 10 行就写会漏写表尾——先按 `lark-sheets-read-data` 确认真实数据末行(准则 3
### 计算:原生工具优先,代码兜底(强化准则 7
@@ -122,16 +129,16 @@ metadata:
### 易漏陷阱
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框,新行回落默认高度截断长文本;插行填长文本前读相邻行 `row_height`,用 `+batch-update``+rows-resize` 补齐。
- **公式容错**:日期 / 查找 / 数值转换公式用 `IFERROR` 包裹;写完读结果列首末各 5 行`#VALUE!` / `#REF!` / `#DIV/0!`,然后继续`+formula-verify` `status='success'`;同一方案试错上限 3 次。
- **`+dim-insert` 不继承行高**:只继承值 / 公式 / 边框;插行填长文本前读相邻行 `row_height`,用 `+batch-update``+rows-resize` 补齐。
- **公式容错**:日期 / 查找 / 转换公式用 `IFERROR` 包裹;写完首末各 5 行错误码,再`+formula-verify``status='success'`;同一方案试错上限 3 次。
- **循环引用**:聚合公式引用范围不能含目标 cell 自身或其传递依赖。
- **隐藏行列**`+csv-get` 默认含隐藏行列;`--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,操作前`+workbook-info` 掌握全局。
- **NLP 任务分批**:语义理解 / 翻译 / 改写 / 分类等用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量大必须分批(通常 30 行 / 批),每批处理完即时写回,单批生成通常 ≤ 300 行,多批用 `+batch-update`
- **隐藏行列**`+csv-get` 默认含隐藏行列;`--skip-hidden=true` 只看可见,但返回行序号与实际行号不再对应。
- **跨 sheet 对象**:图表 / 条件格式 / 透视表 / 浮动图片可能分布在多个子表,先 `+workbook-info` 掌握全局。
- **NLP 任务分批**:语义理解 / 翻译 / 打标用 NLP 处理(代码只做分批 / 行号映射 / 写回);数据量分批( 30 行 / 批)即时写回,多批用 `+batch-update`
## References
本 skill 的 reference 分两组:先读**通用方法与规范**(横切所有任务的样式公式规则,不含具体 shortcut它们规定了"怎么做对"再按操作对象进入**工具参考**查具体 shortcut 与调用细节。编辑类任务务必先过一遍通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
reference 分两组:先读**通用方法与规范**(横切所有任务的样式 / 公式规则,再按操作对象进入**工具参考**查具体 shortcut。编辑类任务务必先过通用方法与规范连同上方「飞书表格编辑准则」对所有工具参考一律生效。
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut
@@ -164,42 +171,22 @@ metadata:
## 公共 flag 速查
各 reference 的每个 shortcut 标题下用一行徽章标注该 shortcut 支持的公共 / 系统 flag,例如
- `_公共四件套 · 系统:--dry-run_` — URL/token + sheet 定位(两组各**必给一个**,详见下方「公共 flag」`--dry-run`
- `_公共URL/token无 sheet 定位) · 系统:--yes、--dry-run_` — 只接 URL/token常见于 `+batch-update` 等不强制 sheet 定位的 shortcut
徽章里只列名字。type / 必填 / 描述都在本段统一声明:
各 reference 的 shortcut 标题下用一行徽章标注支持的公共 / 系统 flag(如 `_公共四件套 · 系统:--dry-run_``_公共URL/token无 sheet 定位…_` 表示只接 URL/token。type / 必填 / 描述在本段统一声明
### 公共 flag定位资源
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR**每组都必须给且只能给一个**XOR = 二选一必填,不是"可选"
1. **spreadsheet 定位(必填)**`--url` `--spreadsheet-token` 二选一**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突
- **`--url` 解析 `/sheets/``/spreadsheets/``/wiki/` 三种链接**(从路径里抽出 token也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token
- **`/wiki/` 知识库链接可直接传 `--url`**:会自动定位到链接背后的电子表格;若该链接背后不是电子表格(而是文档 / 多维表格等),则报错
- **例外**`+workbook-create`(新建表 + 可选写入数据)与 `+workbook-import`(把本地文件导入为新表)都产出一张**还不存在**的表格,**不接受任何 spreadsheet / sheet 定位 flag**——`+workbook-create` 只有 `--title` / `--folder-token` / `--values` / `--styles` / `--sheets``+workbook-import` 只有 `--file`(必填)/ `--folder-token` / `--name`
2. **sheet 定位(公共四件套 shortcut 必填)**`--sheet-id``--sheet-name` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --sheet-id or --sheet-name`
- ⚠️ **不确定 sheet 名时禁止直接猜 `Sheet1`**:除非用户对话明确说出 sheet 名 / id或上下文之前的工具调用 / URL 锚点 `?sheet=xxx`)已经出现过具体值,否则**第一步先调 `+workbook-info --url "..."`**(或 `--spreadsheet-token`)拿 `sheets[].sheet_id` / `sheets[].title` 列表再选。中文环境下子表常叫"数据" / "Sheet"(无数字)/ "工作表 1" / 业务名,猜 `Sheet1` 大概率撞 `sheet not found`,比先查多耗一次失败调用 + 重试
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:即使写了 `--range 'Sheet1!A1:B2'`,仍**必须**额外传 `--sheet-id``--sheet-name`,否则照样报上面的错。
- ⚠️ **A1 reference 含 `!`**`--source` / `--range` / `--ranges`**:整段用单引号包裹**,如 `--range 'Sheet1!A1:B2'`——单引号能挡住 bash 的 history expansion`!` 被拦成 `event not found`;双引号挡不住;别改用 `set +H`,原因见下方「复合 JSON / 大入参」。sheet 名含特殊字符(`-` / 空格 / 非 ASCII需在内部按 A1 标准再包一层单引号时,用 `'\''` 转义保持外层单引号,如 `--source ''\''Sales-2025'\''!A1:D100'`
- **例外**:徽章标为 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`**不接受也不需要** sheet 定位,只给一组 spreadsheet 定位即可。`+pivot-create``--target-sheet-id` / `--target-sheet-name`XOR可都不传落点细节见 `lark-sheets-pivot-table`)。
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--url` | string | 二选一必填(与 `--spreadsheet-token` | spreadsheet 或 wiki URL |
| `--spreadsheet-token` | string | 二选一必填(与 `--url` | spreadsheet token |
| `--sheet-id` | string | 二选一必填(与 `--sheet-name`;仅公共四件套 shortcut | 工作表 reference_id |
| `--sheet-name` | string | 二选一必填(与 `--sheet-id`;仅公共四件套 shortcut | 工作表名称 |
**统一调用范式**(公共四件套 shortcut 的所有示例都遵循此形状,两组定位缺一不可):
1. **spreadsheet 定位(必填)**`--url`(解析 `/sheets/``/spreadsheets/``/wiki/` 三种链接wiki 链接自动定位背后的电子表格)`--spreadsheet-token`(裸 token二选一**例外**`+workbook-create` / `+workbook-import` 产出**还不存在**的表,不接受任何定位 flag
2. **sheet 定位(公共四件套 shortcut 必填)**`--sheet-id``--sheet-name` 二选一
- ⚠️ **不确定 sheet 名时禁止猜 `Sheet1`**:除非对话或上下文已出现具体值,第一步先 `+workbook-info``sheets[].sheet_id/title` 再选——中文表的子表常叫"数据"/"工作表 1"/业务名,猜名大概率撞 `sheet not found`
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:仍必须传 `--sheet-id` / `--sheet-name`
- ⚠️ **A1 引用含 `!` 时整段用单引号包裹**`--range 'Sheet1!A1:B2'`,挡 bash history expansion别用 `set +H`sh/dash 下非法。sheet 名含 `-`/空格需内层再包单引号时用 `'\''` 转义:`--source ''\''Sales-2025'\''!A1:D100'`
- **例外**:徽章标 `_公共URL/token无 sheet 定位…_` 的 shortcut`+workbook-info` / `+workbook-export` / `+batch-update` / `+dropdown-update|delete` / `+cells-batch-set-style` / `+cells-batch-clear` / `+sheet-create`)不接受 sheet 定位。`+pivot-create``--target-sheet-id/name`XOR可都不传
```bash
lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
# workbook 定位:--url "..." 或 --spreadsheet-token "..." (二选一,必给)
# sheet 定位: --sheet-id "$SID" 或 --sheet-name "<真实表名>" (二选一,必给;占位符不要原样填)
# 例lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
# 注意:真实表名不要直接填 "Sheet1"——大多数表的子表不叫这个;先 +workbook-info 拿 sheets[].title 再代入。
# 统一调用范式:两组定位缺一不可(占位符别原样填;表名先 +workbook-info 查)
lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
```
### 系统 flag
@@ -208,27 +195,27 @@ lark-cli sheets <shortcut> <workbook 定位> <sheet 定位> <其它 flag>
| --- | --- | --- | --- |
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用;多步操作会输出每个子操作的请求模板 |
| `--yes` | bool | 是(仅 `high-risk-write` | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起任何调用、不需要其它 required flag。 `--flag-name <name>` 搭配指定查哪个 flag省略 `--flag-name` 时列出该 shortcut 所有可查询的 flag。**仅在 shortcut 含复合 JSON flag 时有效**——判断方法:该 shortcut 的 Flags 表里出现类型标注为「复合 JSON」的 flag`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options`)即支持;纯标量 flag 的 shortcut 不支持。 |
| `--flag-name` | string | 否 | 配合 `--print-schema` 使用,指定要打印 JSON Schema 的 flag 名不带 `--` 前缀,如 `cells` / `properties` / `operations`。 |
| `--print-schema` | bool | 否 | 本地打印复合 JSON flag 的 JSON Schema 并退出,不发起调用、不需要其它 required flag。搭配 `--flag-name` 指定查哪个 flag省略时列出该 shortcut 可查询的 flag。仅对含复合 JSON flag 的 shortcut 有效。 |
| `--flag-name` | string | 否 | 配合 `--print-schema`flag 名不带 `--` 前缀`cells` / `properties`)。**支持点分路径切片**`--flag-name properties.snapshot.plotArea.axes` 只打印该子树,大 schemachart 的 properties 约 1700 行)按需取,别整篇翻页。 |
**Agent 使用提示**:写复合 JSON flag`--cells` / `--properties` / `--operations` / `--border-styles` / `--sort-keys` / `--options` 等)时,如果对结构不确定,先跑 `lark-cli sheets <shortcut> --print-schema --flag-name <name>` 把完整 JSON Schema 读出来再构造 payload比靠 reference 的速查表更精确也避免因为字段拼写或缺失被服务端拒绝。reference 的 `## Schemas` 段只给一层结构,深层只能靠 `--print-schema``## Examples` 的真实示例
> ⚠️ **high-risk-write 命令清单(首次调用就带 `--yes`,别等 exit 10 再补;或先 `--dry-run` 预览)**`+batch-update`、`+cells-clear`、`+cells-batch-clear`、`+sheet-delete`、`+dim-delete`、`+dropdown-delete`,以及各对象删除 `+chart-delete` / `+pivot-delete` / `+cond-format-delete` / `+filter-delete` / `+filter-view-delete` / `+sparkline-delete` / `+float-image-delete`
**Agent 使用提示**:写复合 JSON flag 前对结构不确定时,先 `--print-schema --flag-name <name>`(深层字段用点分路径切片)再构造 payload。图表任务必须先读 `lark-sheets-chart`:能用 `+chart-create-basic` / `+chart-data-update` / `+chart-config-update` 的语义参数就不得探查 schema 或构造 snapshot互不依赖的多图创建 / 更新用一次 `+batch-update --continue-on-error`;只有单系列、单数据点或高级引擎字段无语义参数时,才用 `+chart-create --print-example <type>` 或点分 schema 构造完整 snapshot。reference 的 `## Schemas` 段只给一层结构。
### flag 内容类型与输出约定(术语速记)
- flag 表里 JSON 类入参三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 取完整结构**简单 JSON** = 一维 / 二维标量数组(如 `["sheet1!A1:B2",...]` / `[["alice",95]]`,结构简单无需 print-schema**非 JSON 文本** = 原样文本(如 CSV`--print-schema` 只对**复合 JSON** flag 有效(同一 shortcut 的简单 JSON flag 如 `--colors` 不在此列)
- **envelope**:所有 shortcut 返回统一外层结构 `{ok, identity, data, ...}`。正文里 `envelope.data` 指业务数据层(如 `+csv-get``annotated_csv`;写操作不会自动回读,如需校验自行调用对应的 `+*-list` / `+*-get` / `+cells-get`
- JSON 类入参三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 可查**简单 JSON** = 一二维标量数组;**非 JSON 文本** = 原样文本(如 CSV`--print-schema` 只对复合 JSON flag 有效。
- **envelope**:所有 shortcut 返回统一外层 `{ok, identity, data, ...}`;写操作不会自动回读,校验自行调用 `+*-list` / `+*-get` / `+cells-get`
## 复合 JSON / 大入参:优先 stdin
flag 帮助里标注支持 **Stdin** 的入参,当 payload 较大、含换行 / 引号等特殊字符,或已经落在某个文件里时,优先用 stdin`-`)传入,避免命令行超长与 shell 转义问题
推荐写法payload 写到用户项目目录之外的临时文件(放系统临时目录,避免污染项目),再用 stdin 喂进去:
大 payload`--operations` / `--cells` / `--sheets` / `--styles` / `--properties`…)、或含换行 / 引号 / `!` 等特殊字符时,优先 heredoc stdin`-`)传入,避免命令行超长与 shell 转义问题
```bash
# TMPFILE 指向系统临时目录下的 payload 文件(脚本里用 tempfile.gettempdir() / os.tmpdir() 等取临时目录)
lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "A1:B2" --cells - < "$TMPFILE"
lark-cli sheets +batch-update --url "..." --yes --operations - <<'JSON'
[{"shortcut":"+cells-set","input":{...}}]
JSON
```
**参数含特殊字符(`!` / 引号 / 空格 / 非 ASCII用单引号包裹该参数即可不要起手 `set +H` 之类的 shell 开关来防转义。** `set +H`(关 bash history expansion`sh` / `dash` 下是非法选项(`set: Illegal option -H`)、会让整条命令直接失败;而单引号挡得住 `!` 的 history expansion否则报 `event not found`),对 bash 与 `sh` / `dash` 一致安全。参数本身含单引号、或 payload 较大时,按上文走 stdin
**`@file` 接绝对路径会被拒,且被拒后不要照报错提示做。** `@file` 出于安全只接受 cwd 下的相对路径,传 cwd 之外的绝对路径会被拒。此时报错会建议"先 cd 到目标目录,或改用相对路径"——**两条都不要照做**cd 过去、或把临时文件写进用户项目目录,都会污染工作目录。正解是改用 stdin`--<flag> - < 文件`)。
- **stdin 每次调用只能给一个 flag**`+table-put` 同时传 `--sheets``--styles` 两个大 JSON 时,一个走 `-`、另一个走 `@./styles.json``@file` 只接受 cwd 下相对路径,**绝对路径会被拒**;正解是 stdin别 cd、别把临时文件写进用户项目目录
- **参数含特殊字符时用单引号包裹即可,不要 `set +H`**sh/dash 下非法直接报错);参数本身含单引号或 payload 大时走 stdin。

View File

@@ -5,8 +5,8 @@
`+batch-update` 把多次写入打包成单次请求,但每个子操作仍受编辑类任务硬性默认规则约束:
1. **目标 range 必须落在用户授权范围内**:除用户明示要修改的区域外,子操作禁止扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,用 `+csv-get``+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与本地脚本预先计算的预期值对照
3. **预期条数前置断言**:涉及"批量填充 N 行""对 M 个区域分别写入"时,先把 N、M 硬编码进代码,回读后断言实际等于预期;不一致就再发一轮 `+batch-update` 补齐,禁止交付半成品。
2. **批次完成后必须回读校验**:整个 `+batch-update` 执行成功后,单元格写入`+csv-get``+cells-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末);图表写入用一次 `+chart-list` 核对对象数量、类型、系列和范围
3. **预期条数前置断言**:涉及"批量填充 N 行""对 M 个区域分别写入"或“每个 / 每天 / 分别各建一张图”时,先从数据数出 N、M 并写进清单;图表场景要断言 operations 中的创建数 = 独立实体图数 + 汇总图数。回读后断言实际等于预期,禁止用一张多系列汇总图替代多张独立图,也禁止交付半成品。
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,**回读校验之后还必须继续执行 `+formula-verify`**。`+batch-update` 的原子提交只保证“写入动作都执行了”,不保证整批公式运行结果 zero-error。
@@ -20,10 +20,11 @@
- 需要对**多个**不同区域执行 `+cells-{merge|unmerge}` 时(如按分组合并多列相同内容)
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`
- 需要对多个区域执行不同写入操作时(多次 `+cells-set` + `+cells-clear` 等组合)
- 需要创建多张基础图,或统一更新多张图的数据源、标题、坐标轴、图例、标签、堆叠和平滑配置时(多个 `+chart-create-basic` / `+chart-data-update` / `+chart-config-update`
**行高列宽批量不走这里**:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(如 `--widths '{"A":100,"C:E":120}'`,见 `lark-sheets-range-operations`一次调用原子完成map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求——`+batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品
当同一工具需要对多个区域重复调用时,**必须**改用 `+batch-update` 合并为单次请求。存在依赖关系的操作保持默认严格事务(要么全成功要么整批回滚);互不依赖的多图表创建/更新使用 `--continue-on-error`,保留成功图表并根据逐项错误只重试失败项
**公式相关批处理的默认闭环**
- 写前:先读 `lark-sheets-formula-translation`,把公式改写成飞书可执行语义。
@@ -112,7 +113,7 @@ _公共URL/token无 sheet 定位) · 系统:`--yes`、`--dry-run`_
_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_
**数组项**(类型 object
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +chart-create-basic / +chart-config-update / +chart-data-update / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id或 sheet_name但不含 spreadsheet token/url后者只在顶层 …
### `+cells-batch-set-style` `--border-styles`
@@ -169,6 +170,19 @@ lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --
> ]
> ```
> **多图表组合**:先完成全部辅助数据,再把每张图的完整语义输入放进同一个批次,并在顶层传 `--continue-on-error`;每项同时记录精确表头范围、数据方向和预期系列数。批次完成后,每个受影响的 sheet 各调用一次 `+chart-list`,不要每创建一张图就读取、调整数据后再删除重建。若数据范围或系列数不符,用 `+chart-data-update` 修正已有图表,不要删除后重建。
>
> ```json
> [
> {"shortcut":"+chart-create-basic","input":{"sheet_name":"Sheet1","chart_type":"column","data_range":"'Sheet1'!A1:C10","title":"分类对比","anchor_cell":"F2"}},
> {"shortcut":"+chart-create-basic","input":{"sheet_name":"Sheet1","chart_type":"line","data_range":"'Sheet1'!E1:G10","title":"趋势变化","anchor_cell":"F18"}}
> ]
> ```
>
> ```bash
> lark-cli sheets +batch-update --url "..." --operations @ops.json --continue-on-error --yes
> ```
### `+cells-batch-set-style`
多 range 应用同一组 style服务端走 `+batch-update` 原子事务):

View File

@@ -2,18 +2,30 @@
## 真对象硬约束
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过 `+chart-{create|update|delete}` 创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过图表创建命令创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:交付后 `+chart-list` 必须能返回该对象。
## 使用场景
读写图表对象。本 reference 覆盖 4 个 shortcut
读写图表对象。基础创建和常用更新优先用语义 shortcut只在高级配置时使用原始 snapshot
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 |
| 创建/更新/删除图表 | `+chart-{create|update|delete}` | 对图表对象执行写入操作 |
| 按类型和范围创建基础图 | `+chart-create-basic` | 支持 column/bar/line/area/pie/scatter/combo/radar、行/列方向与整图配色;无需构造 snapshot |
| 修正已有图表的数据范围或方向 | `+chart-data-update` | 服务端重建数据映射,保留标题、样式、位置和尺寸 |
| 批量创建或更新多个独立图表 | `+batch-update --continue-on-error` | 保留成功图表,并逐项返回失败原因;只重试失败项 |
| 更新标题、轴、图例、标签、堆叠、平滑或整图配色 | `+chart-config-update` | 无需回写 snapshot未传字段保持不变 |
| 高级创建/更新、删除图表 | `+chart-{create|update|delete}` | 按系列/数据点精细设置等高级需求才使用原始 properties |
典型工作流:先读取现有图表了解配置 → 执行创建/更新/删除 → 再次读取验证结果
典型工作流:先确认表头和精确数据范围,用 `+chart-create-basic` 一次创建并尽量在同次调用中带上已知标题/轴/标签要求;创建后用 `+chart-list` 验证。已有图表的数据范围或方向错误时用 `+chart-data-update`,常用配置修正用 `+chart-config-update`。只有用户要求单个系列、数据点或高级引擎字段时,才读取现有 snapshot 并调 `+chart-update --properties`。不要为了常用配置先输出整份 schema也不要删除重建已经创建成功的图表
**多图表工作流**:先完成所有辅助数据和表头,列出每张目标图的类型、精确数据范围、标题和落点;确认清单后,用一次 `+batch-update --continue-on-error` 批量执行 `+chart-create-basic`。图表之间独立时允许部分成功:按返回的逐项结果定位失败图表,只重试失败项,不要重复创建成功图表。批次后每个受影响的 sheet 各调用一次 `+chart-list`,验证数量、类型、系列和范围。数据尚未稳定时不要提前创建图表;已经成功创建的图表有数据源差异时批量使用 `+chart-data-update`,有配置差异时批量使用 `+chart-config-update`,不要删除重建。
**数量词必须展开**:用户说“每个 / 每天 / 分别 / 逐一 / 各一张图”时,先从数据中数出实体数 `N`,把这 `N` 张图逐项写进清单,再加上其它汇总图得到目标总数 `M`;一个包含全部实体的多系列图不能替代这 `N` 张独立图。批次前断言 operations 中恰有 `M` 个图表创建,批次后断言图表总数、逐图标题与实体集合一致。
**范围与系列前置校验**:清单中同时记录每张图的表头范围、纳入列、明确排除列、数据方向和预期系列数。表头在首列时用默认 `--data-direction column`;表头在首行、每行代表一个系列时用 `--data-direction row`。创建前根据实际表头确认边界,不凭字母猜范围;创建后范围、方向或系列数不符时,使用 `+chart-data-update` 修正,服务端会重建 `refs` / `dim1` / `dim2.series` 并保留其它配置,不要删除后重建。批次跨多个 sheet 时,每个受影响的 sheet 各调用一次 `+chart-list`
**整图配色优先走语义参数**:只要求统一主题或一组系列颜色时,在创建时传 `--color-palette``--colors`,已有图表用 `+chart-config-update` 更新;二者互斥。只有指定某个系列或某个数据点的颜色时才使用原始 snapshot。
## 需求→图表类型映射(创建前必查)
@@ -27,7 +39,9 @@
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
**`--properties` 结构锚点(构造前必读)**`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。完整结构以 `lark-cli sheets +chart-create --print-schema --flag-name properties` 为准。
**`--properties` 结构锚点(构造前必读)**`--properties` 顶层只有 `position` / `offset` / `size` / `snapshot` 四个字段,**没有**顶层 `data`,也没有再嵌一层 `properties`。图表数据配置全部挂在 `snapshot.data` 下——下文及示例里出现的 `refs` / `headerMode` / `dim1` / `dim2` / `nameRef` 一律指 `snapshot.data.refs` / `snapshot.data.headerMode` / `snapshot.data.dim1` / `snapshot.data.dim2`(及其下的 `serie.nameRef` / `series[].nameRef`);样式 / 堆叠 / 数据标签等在 `snapshot.plotArea` 下。**构造起点优先用 `lark-cli sheets +chart-create --print-example <column|bar|line|area|pie|scatter|radar|combo>` 拿最小可用模板改参**(本地即时返回);查深层字段用点分路径切片 `--print-schema --flag-name properties.snapshot.plotArea.axes`,别整篇 dump 翻页。完整结构以 `--print-schema --flag-name properties` 为准。
**`+chart-update` 局部更新硬规则(更新前必读)**:默认只在 `--properties` 中传实际变化的字段,未传字段保持不变;不要复制并回写完整 snapshot。`snapshot` 内普通对象递归合并,`refs` / `axes` / `series` 等数组整体替换——修改数组中的一项时,应先读取当前数组、改好后只回写该完整数组,不需要携带 snapshot 的其它字段。`snapshot.data.isStaticData` 不能通过 update 改变;需要切换静态/非静态数据时删除后重建。
**常见配置错误(必须注意)**
- **图表类型选择错误**:用户说"堆积柱形图/百分比堆积"时,应在 `properties.snapshot.plotArea.plot.extra.stack` 中配置堆叠;百分比堆叠需在该 stack 下设置 `percentage: true`。用户说"占比/比例"时,优先考虑饼图或百分比堆积图。注意区分 `column`(柱形图,纵向)与 `bar`(条形图,横向)是两个不同的 type 取值,"对比/各 XX" 类纵向柱默认用 `column`
@@ -104,6 +118,9 @@
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+chart-list` | read | 对象 |
| `+chart-create-basic` | write | 对象 |
| `+chart-config-update` | write | 对象 |
| `+chart-data-update` | write | 对象 |
| `+chart-create` | write | 对象 |
| `+chart-update` | write | 对象 |
| `+chart-delete` | high-risk-write | 对象 |
@@ -118,6 +135,69 @@ _公共四件套 · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 |
### `+chart-create-basic`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-type` | string | required | 图表类型(可选值:`column` / `bar` / `line` / `area` / `pie` / `scatter` / `combo` / `radar` |
| `--data-range` | string | required | 含表头的一个连续 A1 范围,或逗号分隔的同表多范围;对齐且不重叠时保留独立引用,否则合并为最小包围矩形 |
| `--data-direction` | string | optional | 数据系列方向column 表示首列为类别row 表示首行为类别(可选值:`column` / `row`)(默认 `column` |
| `--title` | string | optional | 图表标题 |
| `--subtitle` | string | optional | 图表副标题 |
| `--legend-position` | string | optional | 图例位置hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden` |
| `--x-axis-title` | string | optional | X 轴标题 |
| `--y-axis-title` | string | optional | 左 Y 轴标题 |
| `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 |
| `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--data-labels` | string | optional | 数据标签内容none 隐藏标签;兼容 category_percentage 并自动按 value_percentage 处理(可选值:`none` / `value` / `percentage` / `value_percentage` / `category_percentage` / `category` / `series` |
| `--data-label-position` | string | optional | 数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside` |
| `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent` |
| `--stacked` | bool | optional | 兼容别名;等价于 --stack normal隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--smooth` | bool | optional | 是否使用平滑曲线;支持 --smooth=false 和 --smooth false |
| `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`brandColorSeries@v2` / `rainbowColorSeries@v2` / `complementaryColorSeries@v2` / `converseColorSeries@v2` / `primaryColorSeries@v2` / `singleColorSeries-B-@v2` / `singleColorSeries-W-@v2` / `singleColorSeries-G-@v2` / `singleColorSeries-Y-@v2` / `singleColorSeries-O-@v2` / `singleColorSeries-R-@v2` / `singleColorSeries-D-@v2` |
| `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 |
| `--anchor-cell` | string | optional | 可选图表锚点单元格,如 F2省略时放到数据范围右侧 |
| `--width` | int | optional | 可选图表宽度;必须与 --height 同时传 |
| `--height` | int | optional | 可选图表高度;必须与 --width 同时传 |
### `+chart-config-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--title` | string | optional | 图表标题 |
| `--subtitle` | string | optional | 图表副标题 |
| `--legend-position` | string | optional | 图例位置hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden` |
| `--x-axis-title` | string | optional | X 轴标题 |
| `--y-axis-title` | string | optional | 左 Y 轴标题 |
| `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 |
| `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90` |
| `--data-labels` | string | optional | 数据标签内容none 隐藏标签;兼容 category_percentage 并自动按 value_percentage 处理(可选值:`none` / `value` / `percentage` / `value_percentage` / `category_percentage` / `category` / `series` |
| `--data-label-position` | string | optional | 数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside` |
| `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent` |
| `--stacked` | bool | optional | 兼容别名;等价于 --stack normal隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--smooth` | bool | optional | 是否使用平滑曲线;支持 --smooth=false 和 --smooth false |
| `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`brandColorSeries@v2` / `rainbowColorSeries@v2` / `complementaryColorSeries@v2` / `converseColorSeries@v2` / `primaryColorSeries@v2` / `singleColorSeries-B-@v2` / `singleColorSeries-W-@v2` / `singleColorSeries-G-@v2` / `singleColorSeries-Y-@v2` / `singleColorSeries-O-@v2` / `singleColorSeries-R-@v2` / `singleColorSeries-D-@v2` |
| `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 |
### `+chart-data-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--data-range` | string | required | 新的含表头数据范围;支持逗号分隔的同表多范围,错位或重叠时自动合并 |
| `--data-direction` | string | optional | 数据系列方向;省略时沿用现有图表方向(可选值:`column` / `row` |
| `--dim1-index` | int | optional | 类别/X 轴维度在数据范围中的 1-based 索引;省略时使用第 1 个维度 |
| `--dim2-indexes` | string | optional | 值/Y 轴系列在数据范围中的 1-based 索引,逗号分隔;省略时使用除 dim1 外的全部维度 |
### `+chart-create`
_公共四件套 · 系统:`--dry-run`_
@@ -133,7 +213,7 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--properties` | string + File + Stdin复合 JSON | required | 完整或足够完整的图表配置 JSON(先 `+chart-list` 回读再 patch |
| `--properties` | string + File + Stdin复合 JSON | required | 图表配置补丁 JSON;默认只传变化字段,未传字段保持不变;普通对象递归合并,数组整体替换 |
### `+chart-delete`
@@ -155,7 +235,7 @@ _创建/更新的图表属性_
- `position` (object?) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object?) — 必填 { width: number, height: number }
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
- `snapshot` (oneOf?) — 图表快照配置
## Examples
@@ -165,6 +245,86 @@ _创建/更新的图表属性_
输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。
### `+chart-create-basic`
首列自动作为维度,后续列作为数值系列。饼图只使用第二列数值;散点图以首列为 X、后续列为 Y组合图以第二列为左轴柱后续列为右轴折线。数据范围必须包含真实表头如果类别列与数值列不连续可以给 `--data-range` 传逗号分隔的多个范围,例如 `"'Sheet1'!A1:A10,'Sheet1'!K1:L10"`。对齐且不重叠的范围会保留为独立引用,不会把 B:J 的间隔列纳入图表;错行、错列或重叠的范围会自动合并为同一工作表内的最小包围矩形。跨工作表范围仍会拒绝。如果数据子集的表头在范围外,改用高级 `+chart-create` 的 detached 模式。
```bash
# 柱形图:默认放在数据范围右侧
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type column --data-range "'Sheet1'!A1:C10" \
--title "销售额对比" --x-axis-title "品类" --y-axis-title "销售额" \
--legend-position bottom --data-labels value --data-label-position top
# 双轴组合图:首个数值列为左轴柱,其余数值列为右轴折线
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type combo --data-range "'Sheet1'!A1:D13" \
--title "价格与效率" --y-axis-title "价格" --secondary-y-axis-title "效率" \
--anchor-cell F2 --width 700 --height 400
```
多张基础图一次创建。先把所有数据准备完成,再生成 `ops.json`
```json
[
{
"shortcut": "+chart-create-basic",
"input": {
"sheet_name": "Sheet1",
"chart_type": "column",
"data_range": "'Sheet1'!A1:C10",
"title": "分类对比",
"anchor_cell": "F2"
}
},
{
"shortcut": "+chart-create-basic",
"input": {
"sheet_name": "Sheet1",
"chart_type": "line",
"data_range": "'Sheet1'!E1:G10",
"title": "趋势变化",
"anchor_cell": "F18"
}
}
]
```
```bash
lark-cli sheets +batch-update --url "..." --operations @ops.json --continue-on-error --yes
lark-cli sheets +chart-list --url "..." --sheet-name "Sheet1"
```
### `+chart-data-update`
当创建后发现漏列、范围过宽、辅助分类列发生变化、系列选择错误或数据方向错误时,只更新数据源。`--data-direction` 省略时沿用现有图表方向;新范围必须包含表头。默认使用第 1 个维度作为 dim1、其余维度作为 dim2需要精确选择时用 1-based 的 `--dim1-index` 和逗号分隔的 `--dim2-indexes`。工具返回实际采用的 `normalized_data_ranges`,随后用 `+chart-list` 验证范围和系列数。
```bash
# 把遗漏的最后一列纳入原折线图,保留标题、配色、图例和落点
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6"
# 改用按行组织的数据源
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6" --data-direction row
# 第 1 列作为类别,只使用第 4、8 列作为数值系列
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6" --dim1-index 1 --dim2-indexes "4,8"
```
### `+chart-config-update`
只传需要改的字段。`--data-labels none` 会删除数据标签;`--legend-position hidden` 会隐藏图例;`--smooth=false``--smooth false` 都可显式关闭平滑曲线。为减少参数重试,`--stacked` 自动按 `--stack normal` 处理,`--data-labels category_percentage` 自动按 `value_percentage` 处理;新调用仍优先使用规范参数。
```bash
lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--title "新标题" --x-axis-label-angle -45 --legend-position right
lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-labels value_percentage --data-label-position outside --stack percent
```
### `+chart-create`
> **`snapshot.data` 必填 `dim1.serie.index` 或 `dim2.series[].index` 之一**1-based对应 `refs.value` 范围内的列序。schema 允许传空 `{}` 但 server 运行时强制:缺则被拒为 `snapshot.data.dim1.serie.index and dim2.series[].index are both missing; at least one must be set`,即便侥幸通过也只会渲染空图。
@@ -292,22 +452,23 @@ JSON
### `+chart-update`
**Update 三步法**(缺一步会丢字段
1. `+chart-list --chart-id <id>` 拿到完整 snapshot
2. 在拿到的 snapshot 上**局部**修改要改的字段,其余保持不变
3. 把**完整 snapshot** 整个回写到 `--properties.snapshot`
默认提交**最小 patch**。例如只修改标题时,只传标题字段:
```bash
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{
"position":{"row":0,"col":"A"},
"size":{"width":480,"height":320},
"snapshot": <完整快照(由 +chart-list 取回后局部修改)>
"snapshot":{"title":{"text":"新的图表标题"}}
}'
```
> 关键:**不能只提交局部 snapshot**,否则未传字段会被还原为默认值。`+chart-update` 的语义是 PUT整体覆盖不是 PATCH。
只调整尺寸时,不需要传 `snapshot`
```bash
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{"size":{"width":640,"height":360}}'
```
> 数组采用整体替换语义。比如只修改一个坐标轴时,先用 `+chart-list --chart-id <id>` 取得当前 `snapshot.plotArea.axes`,修改目标项后,仅回写 `{"snapshot":{"plotArea":{"axes":[...]}}}`;不要同时回写标题、数据源、图例等未变化字段。
### `+chart-delete`
@@ -325,8 +486,8 @@ lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+chart-create` / `+chart-update``--properties` 必须能解析为合法 JSON`+chart-delete`high-risk-write校验 `--yes``--dry-run` 至少一个。
- `DryRun``+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板"`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
- `Validate`XOR 公共四件套;`+chart-data-update` 要求 `--chart-id``--data-range`,并校验 `--dim1-index` / `--dim2-indexes` 是正整数索引;`+chart-create` / `+chart-update``--properties` 必须能解析为合法 JSON`+chart-delete`high-risk-write校验 `--yes``--dry-run` 至少一个。
- `DryRun``+chart-data-update` / `+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板"`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
- `Execute`:写操作执行后不自动回读;如需确认,自行调用 `+chart-list` 比对结果。
> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`high-risk-write必须 `--yes`。

View File

@@ -44,6 +44,7 @@
- `+csv-get``+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
- 要判断单元格内容是否被行高列宽挤到显示不全(排版检查、调整行高列宽前),给 `+cells-get``--include truncation`:会按字号 / 自动换行 / 行高列宽估算并返回被截断单元格的 `isRowTruncated` / `isColTruncated`(未返回视为未截断)。有额外计算开销,仅需要时才开
**常见配置错误(必须注意)**
- **全量读取导致上下文溢出**:不要对大表(数百行以上)直接用 `+csv-get``+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
@@ -99,8 +100,9 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个(可选值:`value` / `formula` / `style` / `comment` / `data_validation` |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000 has_more 分页 |
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `style` / `comment` / `data_validation` / `truncation` |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆要整表无截断直接用 --output-path 落盘(自动放开为无限);仅当要让结果直接进上下文、又不落盘时才调小(如 25000 has_more 分页 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSONstdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
### `+dropdown-get`
@@ -118,7 +120,8 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆大数据通常宜重定向落盘做分析;仅当要让结果直接进上下文、又不触发文件转存时才调小(如 25000 has_more 分页 |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆要整表无截断直接用 --output-path 落盘(自动放开为无限);仅当要让结果直接进上下文、又不落盘时才调小(如 25000 has_more 分页 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSONstdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
@@ -131,6 +134,8 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--sheet-id` | string | optional | 只读该子表(按 id省略则读所有子表 |
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range会跨过表中部的整行空行 / 整列空列,不会被截断) |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000兜底防爆。底层工具即使不传也有约 50000 的默认截断,故此处显式发送以放宽;要整表无截断请用 --output-path 落盘(自动放开为无限)。 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSONstdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限默认放开为无限**(覆盖 --max-chars 默认),适合大表整表落盘再分析,避免 stdout 被 max_chars 截断。省略时按常规把结果打到 stdout。 |
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
## Examples

View File

@@ -73,7 +73,7 @@
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`--cells` 恒为二维数组(行 × 格),单格也是 `[[{"value":…}]]`;且行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
> **单元格图片 vs 浮动图片(最易选错)**:图若**属于某条记录、要随那行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ **单元格图片**(本工具):用 `+cells-set-image`(最短)或 `+cells-set` 的 `rich_text` + `type: "embed-image"`。只是自由摆放的装饰logo / 水印 / 封面)→ 浮动图片,见 lark-sheets-float-image。别因「浮动图更好控制 / 更熟」默认选浮动图——它承载"对应某记录"的图会随增删行 / 排序错位。
@@ -511,6 +511,8 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
# --sheets 与 --styles 都是大 JSON 时stdin 每次调用只能给一个 flag一个走 -、另一个走 @cwd 相对路径
lark-cli sheets +table-put --url "<表URL>" --sheets - --styles @styles.json < sheets.json
```
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`

View File

@@ -1464,6 +1464,7 @@
<xs:annotation>
<xs:documentation>
表格元素, 用于展示结构化数据
宽高分配规则参考 HTML table 的整体值 / 子项值处理逻辑, 但在 SXSD 中做确定化约束, 以保证跨端实现一致。
边框规则:
- 后设置优先:相邻单元格线条只有一个颜色, 如果均设置, 则右下单元格的设置覆盖左上单元格
- 不设置边框属性时使用默认样式
@@ -1476,6 +1477,8 @@
- id: 表格唯一标识符(可选)
- topLeftX/topLeftY: 左上角坐标
- flipX/flipY: 水平/垂直翻转
- width: 表格目标总宽度(可选)。若设置, 优先用于为未填写列宽的列分配剩余宽度; 当所有列宽均为空时, 所有列均分该值; 当所有列均已填写且该值大于已填写列宽总和时, 多出空间继续分配给现有列, 默认按各列当前宽度作为权重分配; 当已填写列宽之和超过或无法容纳该值时, 保留已填写列宽, 并以最终列宽总和回写 table.width
- height: 表格目标总高度(可选)。若设置, 优先用于为未填写行高的行分配剩余高度; 当所有行高均为空时, 所有行均分该值; 当所有行均已填写且该值大于已填写行高总和时, 多出空间继续分配给现有行, 默认按各行当前高度作为权重分配; 当已填写行高之和超过或无法容纳该值时, 保留已填写行高, 并以最终行高总和回写 table.height
table 子元素:
- colgroup: 列组元素, 用于定义列的宽度
- tr: 行元素, 包含多个单元格
@@ -1484,10 +1487,10 @@
- col: 列元素
col 属性:
- span: 列跨数, 默认为1, 可选
- width: 列宽度, 默认值为110, 可选
- width: 列宽度输入值, 默认值为110, 可选。无 table.width 时, 已填写列保持原值, 空列使用默认值; 有 table.width 时, 已填写列保持原值, 空列优先均分剩余宽度; 若不存在空列且 table.width 大于已填写列宽总和, 则多出空间按各列当前宽度作为权重分配到所有列; 若剩余宽度不足则空列回退为默认值, 并以最终列宽总和作为 table.width
tr 属性:
- height: 行高, 默认为单元格高度
- height: 行高输入值, 默认值为37, 可选。无 table.height 时, 已填写行保持原值, 空行使用默认值; 有 table.height 时, 已填写行保持原值, 空行优先均分剩余高度; 若不存在空行且 table.height 大于已填写行高总和, 则多出空间按各行当前高度作为权重分配到所有行; 若剩余高度不足则空行回退为默认值, 并以最终行高总和作为 table.height。若行高低于内容高度, 需要手动修改行高
tr 子元素:
- td: 单元格元素, 用于显示数据
@@ -1543,6 +1546,8 @@
<xs:attribute name="topLeftY" type="sml:YType" use="required"/>
<xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="width" type="sml:PositiveSize" use="optional"/>
<xs:attribute name="height" type="sml:PositiveSize" use="optional"/>
</xs:complexType>
</xs:element>

View File

@@ -248,6 +248,21 @@
- `<tr>` 内为 `<td>`
- `<td>` 内可放 `<content>`
`<table>` 可选设置 `width``height`,分别表示表格的目标总宽度和总高度:
```xml
<table topLeftX="80" topLeftY="120" width="800" height="300">
<colgroup>
<col width="240"/>
<col/>
</colgroup>
<tr height="80">
<td><content textType="body"><p>表头 1</p></content></td>
<td><content textType="body"><p>表头 2</p></content></td>
</tr>
</table>
```
### `<chart>`
图表元素必须至少包含:

View File

@@ -38,6 +38,8 @@ SXSD_ATTR_ALIASES = {
"fontColor": "color",
}
SERVER_FILLED_SXSD_ATTRS = {"id"}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
@@ -88,6 +90,84 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
return int(value) if value.is_integer() else value
def sum_sizes(sizes: list[int | float]) -> int | float:
return sum(sizes)
def is_filled_size(size: int | float | None) -> bool:
return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
if not sizes:
return sizes
final_sizes = [
size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
for index, size in enumerate(sizes)
]
remaining_size = target_size - sum_sizes(final_sizes[:-1])
if remaining_size >= 1:
final_sizes[-1] = remaining_size
return final_sizes
size_to_redistribute = 1 - remaining_size
for index in range(len(final_sizes) - 2, -1, -1):
reduction = min(final_sizes[index] - 1, size_to_redistribute)
final_sizes[index] -= reduction
size_to_redistribute -= reduction
if size_to_redistribute == 0:
final_sizes[-1] = 1
return final_sizes
final_sizes[-1] = 1
return final_sizes
def solve_weighted_min_layout(
input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
) -> dict[str, Any]:
filled_indexes: list[int] = []
empty_indexes: list[int] = []
base_sizes: list[int | float] = []
for index, size in enumerate(input_sizes):
if is_filled_size(size):
filled_indexes.append(index)
base_sizes.append(size)
else:
empty_indexes.append(index)
base_sizes.append(0)
filled_sum = sum_sizes(base_sizes)
if target_min_size is None:
final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
if not filled_indexes:
average_size = target_min_size / len(input_sizes)
final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
if empty_indexes:
remaining_size = target_min_size - filled_sum
final_sizes = [*base_sizes]
if remaining_size > 0:
average_size = remaining_size / len(empty_indexes)
empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
for index, empty_size in zip(empty_indexes, empty_sizes):
final_sizes[index] = empty_size
else:
for index in empty_indexes:
final_sizes[index] = default_size
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
ratio = max(1, target_min_size / filled_sum)
actual_size = max(target_min_size, filled_sum)
if ratio == 1:
return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
def strip_xml(value: str) -> str:
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
stripped = re.sub(r"<[^>]+>", " ", stripped)
@@ -515,8 +595,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
kind, attrs = match.group(1), match.group(2)
content = ""
if kind == "shape":
close_index = slide_xml.find("</shape>", match.end())
if kind in {"shape", "table"}:
close_index = slide_xml.find(f"</{kind}>", match.end())
if close_index != -1:
content = slide_xml[match.end() : close_index]
@@ -525,6 +605,15 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
y = extract_numeric_attribute(attrs, "topLeftY")
width = extract_numeric_attribute(attrs, "width")
height = extract_numeric_attribute(attrs, "height")
rotation = extract_numeric_attribute(attrs, "rotation") or 0
table_layouts: dict[str, dict[str, Any] | None] = {}
if kind == "table":
width, table_layouts["width"] = resolve_table_dimension(
content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
)
height, table_layouts["height"] = resolve_table_dimension(
content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
)
if all(value is not None for value in [x, y, width, height]):
element = {
"id": element_id,
@@ -534,8 +623,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
"y": y,
"width": width,
"height": height,
"rotation": rotation,
"order": len(elements),
}
if kind == "table":
element.update(
{
"declared_width": extract_numeric_attribute(attrs, "width"),
"declared_height": extract_numeric_attribute(attrs, "height"),
"table_layouts": table_layouts,
}
)
if kind == "shape":
element.update(
{
@@ -867,11 +965,158 @@ def detect_whiteboard_external_overlaps(
return issues
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
rotation %= 360
if math.isclose(rotation, 0, abs_tol=1e-9):
return bbox
radians = math.radians(rotation)
sine = abs(math.sin(radians))
cosine = abs(math.cos(radians))
sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
rotated_width = element["width"] * cosine + element["height"] * sine
rotated_height = element["width"] * sine + element["height"] * cosine
return {
"x": element["x"] - (rotated_width - element["width"]) / 2,
"y": element["y"] - (rotated_height - element["height"]) / 2,
"width": rotated_width,
"height": rotated_height,
}
def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] == "text")
):
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
"top": max(-bbox["y"], 0),
"right": max(bbox["x"] + bbox["width"] - slide_width, 0),
"bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
}
overflow_details = [
f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
]
if not overflow_details:
continue
issues.append(
{
"level": "error",
"code": f'{element["kind"]}_out_of_canvas',
"elements": [element["id"]],
"canvas": {"width": slide_width, "height": slide_height},
"bbox": bbox,
"overflow": overflow,
"message": (
f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
f'({", ".join(overflow_details)})'
),
"hint": (
"Move the table inside the canvas, reduce table.width/table.height, or split the table across "
"slides."
if element["kind"] == "table"
else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
),
}
)
return issues
def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
sizes: list[int | float | None] = []
for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
attrs = match.group(1)
span = extract_numeric_attribute(attrs, "span") or 1
span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
return sizes
def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
def resolve_table_dimension(
table_xml: str,
declared_size: int | float | None,
extract_sizes: Any,
default_size: int | float,
) -> tuple[int | float | None, dict[str, Any] | None]:
input_sizes = extract_sizes(table_xml)
if not input_sizes:
return declared_size, None
layout = solve_weighted_min_layout(
input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
)
return layout["actual_size"], layout
def format_size(size: int | float) -> str:
return f"{size:g}"
def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
dimensions = {
"width": ("col", "column widths"),
"height": ("tr", "row heights"),
}
for table in (element for element in elements if element["kind"] == "table"):
for dimension, (child_tag, child_description) in dimensions.items():
target_size = table[f"declared_{dimension}"]
if not is_filled_size(target_size):
continue
layout = table["table_layouts"][dimension]
if layout is None:
continue
actual_size = layout["actual_size"]
if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
continue
issues.append(
{
"level": "info",
"code": "table_resolved_size_mismatch",
"elements": [table["id"]],
"dimension": dimension,
"declared_size": target_size,
"resolved_size": actual_size,
"resolved_sizes": layout["final_sizes"],
"message": (
f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
f"{child_description} resolve to {format_size(actual_size)}px"
),
"hint": (
f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
f"so their resolved total matches {format_size(target_size)}px."
),
}
)
return issues
def lint_slide(
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
) -> dict[str, Any]:
elements = extract_elements(slide_xml)
issues: list[dict[str, Any]] = detect_whiteboard_external_overlaps(elements, slide_width, slide_height)
issues: list[dict[str, Any]] = [
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
*detect_table_layout_size_mismatches(elements),
]
for index, left in enumerate(elements):
for right in elements[index + 1 :]:
@@ -896,7 +1141,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
return {
"file": source_path,
"slide_size": {"width": 960, "height": 540},
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0},
"summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
"issues": [xml_error],
"slides": [],
}
@@ -908,10 +1153,16 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
if namespace_issues:
error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
return {
"file": source_path,
"slide_size": {"width": 960, "height": 540},
"summary": {"slide_count": 0, "error_count": error_count, "warning_count": warning_count},
"summary": {
"slide_count": 0,
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
},
"issues": top_level_issues,
"slides": [],
}
@@ -924,10 +1175,17 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
result = {
"file": source_path,
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
"summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
"summary": {
"slide_count": len(slides),
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
},
"slides": slides,
}
if top_level_issues:

View File

@@ -2,7 +2,12 @@
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import xml_text_overlap_lint
@@ -212,7 +217,8 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
self.assertEqual(result["summary"]["slide_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -596,7 +602,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"])
def test_lint_xml_does_not_check_bounds_or_text_height(self) -> None:
def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -613,8 +619,11 @@ class XmlTextOverlapLintTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(issue["code"], "shape_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -669,7 +678,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_does_not_check_small_out_of_bounds_elements(self) -> None:
def test_lint_xml_allows_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -683,7 +692,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_ignores_obviously_misplaced_large_visuals(self) -> None:
def test_lint_xml_allows_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -698,7 +707,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_allows_reasonable_large_visual_bleed(self) -> None:
def test_lint_xml_allows_full_bleed_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -712,6 +721,339 @@ class XmlTextOverlapLintTest(unittest.TestCase):
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
<img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
<chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100"/>
<whiteboard id="outside-whiteboard" topLeftX="100" topLeftY="500" width="100" height="100"/>
</data>
</slide>
</presentation>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
[
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
],
)
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
</presentation>
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
<shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
<chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan"/>
</data>
</slide>
</presentation>
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(issues_by_element["infinite"]["overflow"], {"left": 10, "top": 0, "right": 0, "bottom": 0})
self.assertEqual(issues_by_element["negative-infinite"]["overflow"], {"left": 0, "top": 10, "right": 0, "bottom": 0})
self.assertEqual(issues_by_element["not-a-number"]["overflow"], {"left": 0, "top": 0, "right": 10, "bottom": 0})
def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="score-table" topLeftX="54" topLeftY="238" width="414" height="385">
<tr><td><content><p>Score</p></content></td></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issue["code"], "table_out_of_canvas")
self.assertEqual(issue["elements"], ["score-table"])
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 0, "bottom": 83})
self.assertEqual(issue["bbox"], {"x": 54, "y": 238, "width": 414, "height": 385})
def test_lint_xml_reports_table_right_overflow_from_declared_bounds(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="wide-table" topLeftX="850" topLeftY="80" width="180" height="120">
<tr><td><content><p>Score</p></content></td></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issue["code"], "table_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 70, "bottom": 0})
def test_lint_xml_allows_table_with_declared_bounds_inside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="inside-table" topLeftX="40" topLeftY="120" width="880" height="360">
<tr><td><content><p>Score</p></content></td></tr>
</table>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_reports_resolved_table_bounds_when_declared_sizes_are_missing(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="implicit-size-table" topLeftX="850" topLeftY="480">
<colgroup><col/><col/></colgroup>
<tr><td/><td/></tr>
<tr><td/><td/></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issue["code"], "table_out_of_canvas")
self.assertEqual(issue["bbox"], {"x": 850, "y": 480, "width": 220, "height": 74})
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 110, "bottom": 14})
def test_lint_xml_uses_resolved_table_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="resolved-overflow-table" topLeftX="800" topLeftY="80" width="100" height="40">
<colgroup><col width="100"/><col width="100"/></colgroup>
<tr height="40"><td/><td/></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issues = result["slides"][0]["issues"]
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(canvas_issue["bbox"], {"x": 800, "y": 80, "width": 200, "height": 40})
self.assertEqual(canvas_issue["overflow"]["right"], 40)
self.assertEqual(mismatch_issue["dimension"], "width")
self.assertEqual(mismatch_issue["resolved_size"], canvas_issue["bbox"]["width"])
def test_lint_xml_uses_the_same_anonymous_table_id_for_all_table_diagnostics(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="40" topLeftY="40" width="200" height="40"/>
<img id="logo" src="token" topLeftX="40" topLeftY="100" width="40" height="40"/>
<table topLeftX="900" topLeftY="80" width="100" height="40">
<colgroup><col width="100"/><col width="100"/></colgroup>
<tr height="40"><td/><td/></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issues = result["slides"][0]["issues"]
canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
self.assertEqual(canvas_issue["elements"], ["table-3"])
self.assertEqual(mismatch_issue["elements"], ["table-3"])
def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_declared(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="size-mismatch" topLeftX="40" topLeftY="120" width="200" height="80">
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
<tr height="40"><td/><td/><td/></tr>
<tr height="60"><td/><td/><td/></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "info")
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
self.assertEqual(issues_by_dimension["height"]["resolved_sizes"], [40, 60])
self.assertEqual(issues_by_dimension["height"]["resolved_size"], 100)
def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactly(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="size-match" topLeftX="40" topLeftY="120" width="300" height="100">
<colgroup><col width="100"/><col/></colgroup>
<tr height="40"><td/><td/></tr>
<tr><td/><td/></tr>
</table>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<table id="narrow-table" topLeftX="40" topLeftY="120" width="1">
<colgroup><col/><col/></colgroup>
<tr><td/><td/></tr>
</table>
</data>
</slide>
</presentation>
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["dimension"], "width")
self.assertEqual(issue["resolved_sizes"], [1, 1])
self.assertEqual(issue["resolved_size"], 2)
def test_fill_last_size_gap_preserves_target_when_positive_sizes_are_possible(self) -> None:
final_sizes = xml_text_overlap_lint.fill_last_size_gap([10, 10], 3)
self.assertEqual(final_sizes, [2, 1])
self.assertEqual(sum(final_sizes), 3)
def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) -> None:
cases = {
"target-exact": (
"""
<table topLeftX="40" topLeftY="120" width="360" height="150">
<colgroup><col width="100"/><col width="200"/></colgroup>
<tr height="40"><td/><td/></tr><tr height="60"><td/><td/></tr>
</table>
""",
0,
),
"declared-size-exceeds-target": (
"""
<table topLeftX="40" topLeftY="120" width="200" height="80">
<colgroup><col span="2" width="100"/><col width="50"/></colgroup>
<tr height="40"><td/><td/><td/></tr><tr height="60"><td/><td/><td/></tr>
</table>
""",
2,
),
"remaining-space-insufficient": (
"""
<table topLeftX="40" topLeftY="120" width="80" height="30">
<colgroup><col width="80"/><col/></colgroup>
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
</table>
""",
2,
),
"no-target-size": (
"""
<table topLeftX="40" topLeftY="120">
<colgroup><col width="80"/><col/></colgroup>
<tr height="40"><td/><td/></tr><tr><td/><td/></tr>
</table>
""",
0,
),
}
script_path = Path(xml_text_overlap_lint.__file__).resolve()
with tempfile.TemporaryDirectory() as temp_dir:
for name, (table_xml, expected_info_count) in cases.items():
with self.subTest(case=name):
input_path = Path(temp_dir) / f"{name}.xml"
input_path.write_text(
f"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>{table_xml}</data></slide>
</presentation>
""",
encoding="utf-8",
)
completed = subprocess.run(
[sys.executable, str(script_path), "--input", str(input_path)],
capture_output=True,
check=False,
text=True,
)
result = json.loads(completed.stdout)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], expected_info_count)
self.assertTrue(
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
result["slides"][0]["issues"],
)
def test_lint_xml_warns_for_whiteboard_external_boundary_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""

View File

@@ -92,7 +92,7 @@ metadata:
### 3. 发送会中文本或会中表情(写操作)
1. 用户明确要求在当前进行中的会议里发送提示、说明、会中表情,或反馈“听不到 / 看不到 / 声音清楚 / 效果不错”时,用 `+meeting-message-send`
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。
2. 输入是长数字 `meeting_id`,不是 9 位会议号。若用户只给 9 位会议号,先按当前身份执行 `+meeting-list-active` 并按 `meeting_no` 匹配,匹配到唯一会议后再发送;不要为了发消息自动入会。发消息只需 `meeting_id`,不要先查 `+detail`
3. 身份必须延续:`meeting_id` 来自用户身份发现,就继续 `--as user`;来自应用身份发现或应用机器人入会,就继续 `--as bot`
4. 文本消息使用 `--text`;会中表情 / 反馈使用 `--emoji-type``--emoji-type` 必须从 reference 里的完整列表中选择,大小写敏感。
5. 支持普通 Feishu reaction emoji`LOVE``SMILE``THUMBSUP`)和 4 个 VC 反馈 key`VC_CanNotSee``VC_NoSound``VC_LooksGood``VC_SoundsClear`)。

View File

@@ -15,6 +15,7 @@ import (
)
func TestBase_BasicWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

View File

@@ -15,6 +15,7 @@ import (
)
func TestBase_RoleWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

View File

@@ -16,6 +16,7 @@ import (
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
func TestCalendar_CreateEvent(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -16,6 +16,7 @@ import (
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
func TestCalendar_ManageCalendar(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -39,6 +39,7 @@ func requireFreebusyEntry(t *testing.T, stdout string, startAt time.Time, endAt
}
func TestCalendar_RSVPWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -15,6 +15,7 @@ import (
)
func TestCalendar_UpdateEventWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -52,6 +52,7 @@ func TestContact_LookupWorkflowAsUser(t *testing.T) {
}
func TestContact_LookupWorkflowAsBot(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -38,7 +38,7 @@ const (
func SkipWithoutUserToken(t *testing.T) {
t.Helper()
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" {
if os.Getenv("LARKSUITE_CLI_USER_ACCESS_TOKEN") != "" || os.Getenv("TEST_USER_ACCESS_TOKEN") != "" {
return
}
@@ -75,6 +75,27 @@ func SkipWithoutUserToken(t *testing.T) {
}
}
func SkipWithoutTenantAccessToken(t *testing.T) {
t.Helper()
token := os.Getenv("TEST_TENANT_ACCESS_TOKEN")
if token == "" {
token = os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN")
}
appID := os.Getenv("TEST_BOT1_APP_ID")
if appID == "" {
appID = os.Getenv("LARKSUITE_CLI_APP_ID")
}
if token == "" || appID == "" {
t.Skip("skipped: tenant test credentials not set")
}
// Scope standard env credentials to tests that explicitly require a live
// tenant token. Keeping TEST_* variables in the gotestsum parent prevents
// config and dry-run CLI subprocesses from activating the env provider.
t.Setenv("LARKSUITE_CLI_APP_ID", appID)
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", token)
}
// DryRunGet reads a field from the dry-run payload inside the standard success envelope.
func DryRunGet(stdout, path string) gjson.Result {
if path == "" {
@@ -225,13 +246,13 @@ func buildCommandEnv(req Request) []string {
overrides[k] = v
}
// Keep user-token injection scoped to user-only test commands so bot
// commands continue to use config-init credentials in the same process.
// commands retain the process-level bot credentials.
if req.DefaultAs == "user" {
if appID := os.Getenv("TEST_BOT1_APP_ID"); appID != "" {
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
overrides["LARKSUITE_CLI_APP_ID"] = appID
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
}
overrides["LARKSUITE_CLI_APP_ID"] = appID
}
if token := os.Getenv("TEST_USER_ACCESS_TOKEN"); token != "" {
overrides["LARKSUITE_CLI_USER_ACCESS_TOKEN"] = token
}
}
for k, v := range overrides {

View File

@@ -113,6 +113,19 @@ func TestSkipWithoutUserToken(t *testing.T) {
assert.True(t, ran)
})
t.Run("returns immediately when test user access token exists", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat-from-test-env")
ran := false
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutUserToken(t)
ran = true
})
require.True(t, ok)
assert.True(t, ran)
})
t.Run("accepts verified local auth status", func(t *testing.T) {
fake := newFakeCLI(t)
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
@@ -146,6 +159,54 @@ func TestSkipWithoutUserToken(t *testing.T) {
})
}
func TestSkipWithoutTenantAccessToken(t *testing.T) {
t.Run("skips when env tenant access token is missing", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "")
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
ran := false
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutTenantAccessToken(t)
ran = true
})
require.True(t, ok)
assert.False(t, ran)
})
t.Run("accepts standard tenant credentials", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "")
t.Setenv("LARKSUITE_CLI_APP_ID", "app-from-env")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "test-token")
ran := false
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutTenantAccessToken(t)
ran = true
})
require.True(t, ok)
assert.True(t, ran)
})
t.Run("scopes shared tenant credentials to the requiring test", func(t *testing.T) {
t.Setenv("TEST_BOT1_APP_ID", "shared-test-app")
t.Setenv("TEST_TENANT_ACCESS_TOKEN", "shared-test-token")
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN", "")
ok := t.Run("inner", func(t *testing.T) {
SkipWithoutTenantAccessToken(t)
assert.Equal(t, "shared-test-app", os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Equal(t, "shared-test-token", os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
})
require.True(t, ok)
assert.Empty(t, os.Getenv("LARKSUITE_CLI_APP_ID"))
assert.Empty(t, os.Getenv("LARKSUITE_CLI_TENANT_ACCESS_TOKEN"))
})
}
func TestRunCmd(t *testing.T) {
t.Run("returns stdout json on success", func(t *testing.T) {
fake := newFakeCLI(t)
@@ -214,6 +275,8 @@ func TestRunCmd(t *testing.T) {
})
t.Run("injects user token env only for user commands", func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_ID", "")
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "")
t.Setenv("TEST_BOT1_APP_ID", "cli_app_test")
t.Setenv("TEST_USER_ACCESS_TOKEN", "uat_test")
@@ -224,6 +287,10 @@ func TestRunCmd(t *testing.T) {
env = buildCommandEnv(Request{DefaultAs: "bot"})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
env = buildCommandEnv(Request{})
assert.NotContains(t, env, "LARKSUITE_CLI_APP_ID=cli_app_test")
assert.NotContains(t, env, "LARKSUITE_CLI_USER_ACCESS_TOKEN=uat_test")
})
t.Run("retries structured retryable service errors by default", func(t *testing.T) {

View File

@@ -17,6 +17,7 @@ import (
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -17,6 +17,7 @@ import (
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
func TestDocs_UpdateWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -16,7 +16,7 @@
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, asserts each delete returns `task_id`, queries every returned task via `drive +task_result --scenario task_check`, and verifies the targets disappear.
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, converging every delete outcome to the resource-gone terminal state: async deletes (non-empty `task_id`) are verified via `drive +task_result --scenario task_check`, sync deletes (empty `task_id`) assert `deleted=true`, and the one verified backend transient (`server_error: "drive task failed"`) passes once the target is confirmed gone (retried up to 3 times otherwise); any other delete failure stays fatal.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -30,7 +30,7 @@
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_AppsPageURL; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; apps `/page/<token>` URL; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size` | dry-run locks URL/token parsing, apps `file_type=apps`, default unresolved filter, omitted all-scope filter, omitted `user_id_type`, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder async deletion |
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |

View File

@@ -15,6 +15,7 @@ import (
)
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
}

View File

@@ -0,0 +1,370 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeleteAsyncAndVerify(t *testing.T) {
t.Run("sync delete without task_id skips task_result", func(t *testing.T) {
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "sync")
t.Setenv("FAKE_WORKFLOW_META_MODE", "gone")
counters := setupFakeWorkflowCounters(t)
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_sync", "docx")
assert.Empty(t, taskID)
assert.Equal(t, "1", readFakeCounter(t, counters.deletes), "sync path must delete exactly once")
assert.Equal(t, "1", readFakeCounter(t, counters.metas), "sync path must still verify the resource is gone")
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults), "sync path must not query task status")
})
t.Run("transient failure with resource gone is tolerated", func(t *testing.T) {
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "fail")
t.Setenv("FAKE_WORKFLOW_META_MODE", "gone")
counters := setupFakeWorkflowCounters(t)
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_transient", "docx")
assert.Empty(t, taskID)
assert.Equal(t, "1", readFakeCounter(t, counters.deletes), "resource already gone must not trigger another delete attempt")
assert.Equal(t, "1", readFakeCounter(t, counters.metas), "transient failure must verify the terminal state")
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
})
t.Run("failed delete retries until async success", func(t *testing.T) {
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
t.Setenv(clie2e.EnvBinaryPath, fake)
t.Setenv("FAKE_WORKFLOW_DELETE_MODE", "fail-then-async")
t.Setenv("FAKE_WORKFLOW_META_MODE", "exists-then-gone")
t.Setenv("FAKE_WORKFLOW_TASK_RESULT_OK", "1")
counters := setupFakeWorkflowCounters(t)
withFastDeleteWorkflowBackoff(t)
taskID := deleteAsyncAndVerify(t, context.Background(), "docx_retry", "docx")
assert.Equal(t, "task_123", taskID)
assert.Equal(t, "2", readFakeCounter(t, counters.deletes))
assert.Equal(t, "2", readFakeCounter(t, counters.metas), "one terminal-state check after the failure plus the final visibility wait")
assert.Equal(t, "1", readFakeCounter(t, counters.taskResults), "async success must verify the task result")
})
}
// TestIsTransientDriveDeleteFailure locks the tolerance boundary: only the one
// verified backend transient may fall through to terminal-state checking, so a
// crash, a protocol regression, or any other error keeps failing the workflow
// even when the resource happens to be gone.
func TestIsTransientDriveDeleteFailure(t *testing.T) {
t.Run("matches compact envelope", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: "Deleting docx tok...\n{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"server_error\",\"message\":\"drive task failed\"}}",
}
assert.True(t, isTransientDriveDeleteFailure(result))
})
t.Run("matches pretty-printed envelope from CI", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: "Deleting docx NTw0...Rngb...\nDelete is async, polling task schedule|7663369798226545963...\n" +
"{\n \"ok\": false,\n \"identity\": \"bot\",\n \"error\": {\n \"type\": \"api\",\n \"subtype\": \"server_error\",\n \"message\": \"drive task failed\"\n }\n}",
}
assert.True(t, isTransientDriveDeleteFailure(result))
})
t.Run("rejects other server errors", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: "{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"server_error\",\"message\":\"internal error\"}}",
}
assert.False(t, isTransientDriveDeleteFailure(result))
})
t.Run("rejects non-server-error subtypes", func(t *testing.T) {
result := &clie2e.Result{
ExitCode: 1,
Stderr: "{\"ok\":false,\"identity\":\"bot\",\"error\":{\"type\":\"api\",\"subtype\":\"permission_denied\",\"message\":\"drive task failed\"}}",
}
assert.False(t, isTransientDriveDeleteFailure(result))
})
t.Run("rejects non-JSON output", func(t *testing.T) {
result := &clie2e.Result{ExitCode: 2, Stderr: "panic: runtime error"}
assert.False(t, isTransientDriveDeleteFailure(result))
})
t.Run("rejects nil result", func(t *testing.T) {
assert.False(t, isTransientDriveDeleteFailure(nil))
})
}
// TestDeleteAsyncAndVerifyRejectsUnexpectedFailure locks the P1 boundary
// end-to-end by re-running this test binary as a subprocess that really calls
// deleteAsyncAndVerify: an unrelated non-zero exit must fail the helper
// immediately — no terminal-state check may rescue it even though meta reports
// the resource gone. Fatalf cannot be observed on the parent *testing.T, so
// the boundary is proven by the child process exiting non-zero AND the meta
// endpoint never being reached. Removing the isTransientDriveDeleteFailure
// guard from the main loop turns this test red.
func TestDeleteAsyncAndVerifyRejectsUnexpectedFailure(t *testing.T) {
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
counters := newFakeWorkflowCounterPaths(t)
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
"FAKE_WORKFLOW_TOKEN": "docx_unexpected",
"FAKE_WORKFLOW_DELETE_MODE": "fail-unexpected",
"FAKE_WORKFLOW_META_MODE": "gone",
})
require.Error(t, err, "deleteAsyncAndVerify must fail the test process on an unexpected delete error\noutput:\n%s", output)
assert.Contains(t, output, "drive +delete failed with an unexpected error", "output:\n%s", output)
assert.Equal(t, "1", readFakeCounter(t, counters.deletes))
assert.Equal(t, "0", readFakeCounter(t, counters.metas), "unexpected failures must not fall through to terminal-state checking")
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
}
// TestDeleteAsyncAndVerifyFailsOnTaskResultFailure proves a non-zero
// drive +task_result exit fails the workflow before the final visibility
// polling: the task-result endpoint is reached once and the meta endpoint
// never.
func TestDeleteAsyncAndVerifyFailsOnTaskResultFailure(t *testing.T) {
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
counters := newFakeWorkflowCounterPaths(t)
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
"FAKE_WORKFLOW_TOKEN": "docx_taskresult",
"FAKE_WORKFLOW_DELETE_MODE": "async",
"FAKE_WORKFLOW_META_MODE": "gone",
// FAKE_WORKFLOW_TASK_RESULT_OK stays unset: +task_result exits 2.
})
require.Error(t, err, "deleteAsyncAndVerify must fail the test process when +task_result fails\noutput:\n%s", output)
assert.Contains(t, output, "drive +task_result failed", "output:\n%s", output)
assert.Equal(t, "1", readFakeCounter(t, counters.deletes))
assert.Equal(t, "1", readFakeCounter(t, counters.taskResults))
assert.Equal(t, "0", readFakeCounter(t, counters.metas), "task-result failure must abort before visibility polling")
}
// TestDeleteAsyncAndVerifyStopsAfterExhaustedRetries proves the transient
// tolerance is bounded: with the resource still present, exactly
// deleteWorkflowMaxAttempts delete attempts (each followed by one terminal
// state check) run before the workflow fails for good.
func TestDeleteAsyncAndVerifyStopsAfterExhaustedRetries(t *testing.T) {
fake := mustWriteDriveDeleteWorkflowFakeCLI(t)
counters := newFakeWorkflowCounterPaths(t)
output, err := runDeleteWorkflowSubprocess(t, fake, counters, map[string]string{
"FAKE_WORKFLOW_TOKEN": "docx_exhausted",
"FAKE_WORKFLOW_DELETE_MODE": "fail",
"FAKE_WORKFLOW_META_MODE": "exists",
"FAKE_WORKFLOW_FAST_BACKOFF": "1",
})
require.Error(t, err, "deleteAsyncAndVerify must fail the test process after exhausting retries\noutput:\n%s", output)
assert.Contains(t, output, "drive +delete failed 3 times", "output:\n%s", output)
assert.Equal(t, "3", readFakeCounter(t, counters.deletes))
assert.Equal(t, "3", readFakeCounter(t, counters.metas))
assert.Equal(t, "0", readFakeCounter(t, counters.taskResults))
}
// runDeleteWorkflowSubprocess re-runs this test binary anchored to the child
// entry point below with the fake CLI and counter files wired in via env.
func runDeleteWorkflowSubprocess(t *testing.T, fake string, counters fakeWorkflowCounters, env map[string]string) (string, error) {
t.Helper()
cmd := exec.Command(os.Args[0], "-test.run=TestDeleteAsyncAndVerifySubprocess$", "-test.v")
cmd.Env = append(os.Environ(),
"FAKE_WORKFLOW_SUBPROCESS=1",
clie2e.EnvBinaryPath+"="+fake,
"FAKE_WORKFLOW_DELETE_STATE="+counters.deletes,
"FAKE_WORKFLOW_META_STATE="+counters.metas,
"FAKE_WORKFLOW_TASK_RESULT_STATE="+counters.taskResults,
)
for k, v := range env {
cmd.Env = append(cmd.Env, k+"="+v)
}
output, err := cmd.CombinedOutput()
return string(output), err
}
// TestDeleteAsyncAndVerifySubprocess is the child entry point driven by
// runDeleteWorkflowSubprocess. It does nothing in a normal test run.
func TestDeleteAsyncAndVerifySubprocess(t *testing.T) {
if os.Getenv("FAKE_WORKFLOW_SUBPROCESS") != "1" {
return
}
if os.Getenv("FAKE_WORKFLOW_FAST_BACKOFF") == "1" {
deleteWorkflowRetryBackoff = time.Millisecond
}
deleteAsyncAndVerify(t, context.Background(), os.Getenv("FAKE_WORKFLOW_TOKEN"), "docx")
}
type fakeWorkflowCounters struct {
deletes string
metas string
taskResults string
}
func newFakeWorkflowCounterPaths(t *testing.T) fakeWorkflowCounters {
t.Helper()
dir := t.TempDir()
return fakeWorkflowCounters{
deletes: filepath.Join(dir, "delete-attempts"),
metas: filepath.Join(dir, "meta-calls"),
taskResults: filepath.Join(dir, "task-result-calls"),
}
}
// setupFakeWorkflowCounters wires per-endpoint call counters into the fake CLI
// so tests can assert exactly which commands ran.
func setupFakeWorkflowCounters(t *testing.T) fakeWorkflowCounters {
t.Helper()
counters := newFakeWorkflowCounterPaths(t)
t.Setenv("FAKE_WORKFLOW_DELETE_STATE", counters.deletes)
t.Setenv("FAKE_WORKFLOW_META_STATE", counters.metas)
t.Setenv("FAKE_WORKFLOW_TASK_RESULT_STATE", counters.taskResults)
return counters
}
func readFakeCounter(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "0"
}
require.NoError(t, err)
return string(data)
}
func withFastDeleteWorkflowBackoff(t *testing.T) {
t.Helper()
original := deleteWorkflowRetryBackoff
deleteWorkflowRetryBackoff = time.Millisecond
t.Cleanup(func() {
deleteWorkflowRetryBackoff = original
})
}
// mustWriteDriveDeleteWorkflowFakeCLI writes a fake lark-cli that emulates the
// drive delete outcomes exercised by deleteAsyncAndVerify. Every endpoint
// bumps a per-endpoint counter when its FAKE_WORKFLOW_*_STATE env is set, so
// tests can assert call contracts. +task_result rejects every call unless
// FAKE_WORKFLOW_TASK_RESULT_OK=1, which proves the sync path never queries
// task status.
func mustWriteDriveDeleteWorkflowFakeCLI(t *testing.T) string {
t.Helper()
script := `#!/bin/sh
bump_counter() {
state="$1"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
next=$((count + 1))
printf '%s' "$next" > "$state"
echo "$count"
}
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
count=0
if [ -n "$FAKE_WORKFLOW_DELETE_STATE" ]; then
count="$(bump_counter "$FAKE_WORKFLOW_DELETE_STATE")"
fi
case "$FAKE_WORKFLOW_DELETE_MODE" in
sync)
echo '{"ok":true,"identity":"bot","data":{"deleted":true,"file_token":"tok","type":"docx"}}'
exit 0
;;
fail)
echo "Deleting docx tok..." >&2
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"server_error","message":"drive task failed"}}' >&2
exit 1
;;
fail-unexpected)
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"invalid_request","message":"file token not found"}}' >&2
exit 1
;;
async)
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","file_token":"tok","type":"docx"}}'
exit 0
;;
fail-then-async)
if [ "$count" -lt 1 ]; then
echo '{"ok":false,"identity":"bot","error":{"type":"api","subtype":"server_error","message":"drive task failed"}}' >&2
exit 1
fi
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","file_token":"tok","type":"docx"}}'
exit 0
;;
esac
echo "unexpected FAKE_WORKFLOW_DELETE_MODE: $FAKE_WORKFLOW_DELETE_MODE" >&2
exit 2
fi
if [ "$1" = "drive" ] && [ "$2" = "+task_result" ]; then
if [ -n "$FAKE_WORKFLOW_TASK_RESULT_STATE" ]; then
bump_counter "$FAKE_WORKFLOW_TASK_RESULT_STATE" > /dev/null
fi
if [ "${FAKE_WORKFLOW_TASK_RESULT_OK:-0}" != "1" ]; then
echo "unexpected +task_result call: $*" >&2
exit 2
fi
echo '{"ok":true,"identity":"bot","data":{"task_id":"task_123","status":"success","failed":false}}'
exit 0
fi
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
count=0
if [ -n "$FAKE_WORKFLOW_META_STATE" ]; then
count="$(bump_counter "$FAKE_WORKFLOW_META_STATE")"
fi
case "$FAKE_WORKFLOW_META_MODE" in
gone)
echo '{"ok":true,"data":{"metas":[]}}'
exit 0
;;
exists)
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
;;
exists-then-gone)
if [ "$count" -lt 1 ]; then
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
exit 0
fi
echo '{"ok":true,"data":{"metas":[]}}'
exit 0
;;
esac
echo "unexpected FAKE_WORKFLOW_META_MODE: $FAKE_WORKFLOW_META_MODE" >&2
exit 2
fi
echo "unexpected fake CLI args: $*" >&2
exit 2
`
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
return binaryPath
}

View File

@@ -5,6 +5,7 @@ package drive
import (
"context"
"strings"
"testing"
"time"
@@ -14,6 +15,8 @@ import (
)
func TestDrive_DeleteAsyncWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
@@ -65,30 +68,117 @@ func createDeleteWorkflowDoc(t *testing.T, ctx context.Context, folderToken, tit
return docToken
}
const deleteWorkflowMaxAttempts = 3
// deleteWorkflowRetryBackoff paces delete retries after a non-retryable
// failure whose target still exists. Unit tests shrink it.
var deleteWorkflowRetryBackoff = driveDeleteVisibilityPoll
// deleteAsyncAndVerify deletes token and converges every server outcome to the
// real postcondition: the resource is gone. Async deletes (non-empty task_id)
// additionally verify the task via drive +task_result; sync deletes (empty
// task_id) skip task polling; non-retryable delete failures (e.g. a transient
// "drive task failed") pass when the resource is already gone and are retried
// up to deleteWorkflowMaxAttempts times otherwise.
func deleteAsyncAndVerify(t *testing.T, ctx context.Context, token, docType string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: "bot",
}, driveDeleteRetry)
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var lastResult *clie2e.Result
for attempt := 1; attempt <= deleteWorkflowMaxAttempts; attempt++ {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: "bot",
}, driveDeleteRetry)
require.NoError(t, err)
lastResult = result
taskID := gjson.Get(result.Stdout, "data.task_id").String()
require.NotEmpty(t, taskID, "delete must return async task_id\nstdout:\n%s", result.Stdout)
if result.ExitCode == 0 {
result.AssertStdoutStatus(t, true)
taskID := gjson.Get(result.Stdout, "data.task_id").String()
if taskID == "" {
// Sync completion: the server deleted the resource inline and
// returned no task to poll.
require.True(t, gjson.Get(result.Stdout, "data.deleted").Bool(), "sync delete must report deleted=true\nstdout:\n%s", result.Stdout)
t.Logf("drive +delete completed synchronously for %s %s (no task_id)", docType, token)
} else {
assertDriveDeleteTaskSucceeded(t, ctx, taskID)
}
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
return taskID
}
// Only the one verified backend transient may fall through to
// terminal-state checking; any other failure is a real regression and
// must not be rescued by the resource happening to be gone.
if !isTransientDriveDeleteFailure(result) {
t.Fatalf("drive +delete failed with an unexpected error on attempt %d\nstdout:\n%s\nstderr:\n%s",
attempt, result.Stdout, result.Stderr)
}
// The failed delete task may still have removed the resource
// server-side, so check the real terminal state before retrying.
deleted, verifyErr := IsDriveResourceDeleted(ctx, token, docType, "bot")
require.NoError(t, verifyErr, "verify %s %s after failed delete attempt %d", docType, token, attempt)
if deleted {
t.Logf("drive +delete attempt %d failed transiently but %s %s is gone: stderr=%s", attempt, docType, token, result.Stderr)
return ""
}
if attempt < deleteWorkflowMaxAttempts {
t.Logf("drive +delete attempt %d failed and %s %s still exists; retrying: stderr=%s", attempt, docType, token, result.Stderr)
time.Sleep(deleteWorkflowRetryBackoff)
}
}
t.Fatalf("drive +delete failed %d times and %s %s still exists\nstdout:\n%s\nstderr:\n%s",
deleteWorkflowMaxAttempts, docType, token, lastResult.Stdout, lastResult.Stderr)
return ""
}
func assertDriveDeleteTaskSucceeded(t *testing.T, ctx context.Context, taskID string) {
t.Helper()
taskResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+task_result", "--scenario", "task_check", "--task-id", taskID},
DefaultAs: "bot",
})
require.NoError(t, err)
taskResult.AssertExitCode(t, 0)
require.NotNil(t, taskResult)
// Fatal exit-code gate first: the non-fatal assert flavor would cascade
// into misleading empty-stdout failures, exactly what this fix removes.
require.Equal(t, 0, taskResult.ExitCode, "drive +task_result failed\nstdout:\n%s\nstderr:\n%s", taskResult.Stdout, taskResult.Stderr)
taskResult.AssertStdoutStatus(t, true)
require.Equal(t, taskID, gjson.Get(taskResult.Stdout, "data.task_id").String(), "stdout:\n%s", taskResult.Stdout)
require.False(t, gjson.Get(taskResult.Stdout, "data.failed").Bool(), "stdout:\n%s", taskResult.Stdout)
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
return taskID
// gjson returns false for an absent field too, so require presence or a
// malformed task envelope would pass validation.
failedField := gjson.Get(taskResult.Stdout, "data.failed")
require.True(t, failedField.Exists(), "task result must report data.failed\nstdout:\n%s", taskResult.Stdout)
require.False(t, failedField.Bool(), "stdout:\n%s", taskResult.Stdout)
}
// isTransientDriveDeleteFailure reports whether a failed drive +delete carries
// the one backend error this workflow tolerates: the async delete task
// transiently reporting a terminal "fail" state (observed as flake in CI; the
// resource is usually deleted regardless). Everything else — crashes, protocol
// regressions, auth or parameter errors — stays fatal.
func isTransientDriveDeleteFailure(result *clie2e.Result) bool {
if result == nil {
return false
}
for _, raw := range []string{result.Stderr, result.Stdout} {
idx := strings.Index(raw, "{")
if idx < 0 {
continue
}
payload := raw[idx:]
if !gjson.Valid(payload) {
continue
}
errObj := gjson.Get(payload, "error")
if errObj.Get("type").String() == "api" &&
errObj.Get("subtype").String() == "server_error" &&
errObj.Get("message").String() == "drive task failed" {
return true
}
}
return false
}

View File

@@ -17,6 +17,7 @@ import (
)
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

View File

@@ -13,6 +13,8 @@ import (
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -19,6 +19,7 @@ import (
// TestDrive_PreviewAndCoverWorkflow verifies preview and cover shortcuts against
// a live Drive workflow, skipping when required bot scopes are unavailable.
func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
@@ -125,15 +126,7 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
InitialDelay: 2 * time.Second,
MaxDelay: 8 * time.Second,
BackoffMultiple: 2,
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil {
return true
}
if result.ExitCode == 0 {
return false
}
return false
},
ShouldRetry: shouldRetryCoverDownload,
})
require.NoError(t, err)
coverResult.AssertExitCode(t, 0)
@@ -156,6 +149,80 @@ func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
})
}
func shouldRetryCoverDownload(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0
}
func TestShouldRetryCoverDownload(t *testing.T) {
for _, tt := range []struct {
name string
result *clie2e.Result
want bool
}{
{name: "nil result", result: nil, want: true},
{name: "successful result", result: &clie2e.Result{ExitCode: 0}, want: false},
{name: "failed result", result: &clie2e.Result{ExitCode: 1}, want: true},
} {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldRetryCoverDownload(tt.result))
})
}
fakeCLI := writeCoverDownloadRetryFakeCLI(t)
for _, tt := range []struct {
name string
succeedAfter string
wantCount string
wantExitCode int
}{
{name: "retries after failure", succeedAfter: "2", wantCount: "2\n", wantExitCode: 0},
{name: "stops after success", succeedAfter: "1", wantCount: "1\n", wantExitCode: 0},
{name: "stops after eight failures", succeedAfter: "9", wantCount: "8\n", wantExitCode: 1},
} {
t.Run(tt.name, func(t *testing.T) {
statePath := filepath.Join(t.TempDir(), "attempt-count")
result, err := clie2e.RunCmdWithRetry(context.Background(), clie2e.Request{
BinaryPath: fakeCLI,
Args: []string{statePath, tt.succeedAfter},
}, clie2e.RetryOptions{
Attempts: 8,
InitialDelay: time.Millisecond,
MaxDelay: time.Millisecond,
BackoffMultiple: 2,
ShouldRetry: shouldRetryCoverDownload,
})
require.NoError(t, err)
require.Equal(t, tt.wantExitCode, result.ExitCode)
count, err := os.ReadFile(statePath)
require.NoError(t, err)
require.Equal(t, tt.wantCount, string(count))
})
}
}
func writeCoverDownloadRetryFakeCLI(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-lark-cli")
script := `#!/bin/sh
state="$1"
succeed_after="$2"
count=0
if [ -f "$state" ]; then
count="$(cat "$state")"
fi
count=$((count + 1))
echo "$count" > "$state"
if [ "$count" -lt "$succeed_after" ]; then
exit 1
fi
exit 0
`
require.NoError(t, os.WriteFile(path, []byte(script), 0o755))
return path
}
// writePreviewFixture writes a local fixture file used by the live workflow.
func writePreviewFixture(t *testing.T, workDir, relPath, content string) {
t.Helper()

View File

@@ -37,6 +37,7 @@ import (
// Expected output: each of the four buckets contains exactly the file we
// expect, with file_token set for the three buckets that have a Drive side.
func TestDrive_StatusWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
@@ -241,6 +242,7 @@ func TestDrive_StatusWorkflow(t *testing.T) {
// modified_time values fetched from the list API, plus the expected new_local /
// new_remote buckets.
func TestDrive_StatusQuickWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)

View File

@@ -31,6 +31,7 @@ import (
// ├── conflict.txt "local" → modified → resolve
// └── unchanged.txt "match" → unchanged → skip
func TestDrive_SyncWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
@@ -249,6 +250,7 @@ func TestDrive_SyncWorkflow(t *testing.T) {
// TestDrive_SyncEmptyDirWorkflow proves that empty local directories are
// created on Drive during +sync, and that a subsequent +status converges.
func TestDrive_SyncEmptyDirWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)

View File

@@ -16,6 +16,7 @@ import (
)
func TestDrive_UploadWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)

View File

@@ -16,6 +16,7 @@ import (
)
func TestDriveVersionWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
if os.Getenv("LARK_DRIVE_VERSION_E2E") == "" {
t.Skip("set LARK_DRIVE_VERSION_E2E=1 to run drive version live workflow")
}

View File

@@ -16,6 +16,7 @@ import (
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
func TestIM_ChatUpdateWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
@@ -70,6 +71,7 @@ func TestIM_ChatUpdateWorkflow(t *testing.T) {
// TestIM_ChatsGetWorkflow tests the im chats get command.
func TestIM_ChatsGetWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
@@ -100,6 +102,7 @@ func TestIM_ChatsGetWorkflow(t *testing.T) {
// TestIM_ChatsLinkWorkflow tests the im chats link command.
func TestIM_ChatsLinkWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -15,6 +15,7 @@ import (
)
func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)

View File

@@ -16,6 +16,7 @@ import (
)
func TestIM_MessageReplyWorkflowAsBot(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -183,6 +183,7 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) {
}
func TestMarkdownCreateWorkflow_WikiParent(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
if os.Getenv("LARK_MARKDOWN_E2E") == "" {
t.Skip("set LARK_MARKDOWN_E2E=1 to run markdown live workflow after backend version support is deployed")
}

View File

@@ -21,6 +21,7 @@ import (
// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations
// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export
func TestSheets_CRUDE2EWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
@@ -174,6 +175,7 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods
func TestSheets_SpreadsheetsResource(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -17,6 +17,7 @@ import (
// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations
func TestSheets_FilterWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -20,6 +20,7 @@ import (
// field exposed via +sheet-info / +workbook-info — so success here is the
// ok=true envelope, not a value comparison).
func TestSheets_GridlineWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -15,6 +15,7 @@ import (
)
func TestSheets_SheetShortcutsWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -32,6 +32,7 @@ import (
// The true used range is A1:F10. The default +table-get must return all 9 data
// rows and 6 columns and report a range covering row 10 / column F.
func TestSheets_TableGetUsedRangeWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -22,6 +22,7 @@ import (
// it back as the same typed shape, locking the dtype + format contract that
// makes round-trip (pipe +table-get into +table-put) work.
func TestSheets_TablePutTypedWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
@@ -89,6 +90,7 @@ func TestSheets_TablePutTypedWorkflow(t *testing.T) {
// adopted sheet carries the typed data we sent (no empty "Sheet1" remains)
// and that --sheets's typed contract holds end-to-end, not just on +table-put.
func TestSheets_WorkbookCreateTypedWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

View File

@@ -27,6 +27,7 @@ import (
// validates the full flow including the async poll and that the resulting
// token is a usable sheet token.
func TestSheets_WorkbookImportWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)

View File

@@ -15,6 +15,7 @@ import (
)
func TestTask_CommentWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)

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