Compare commits

...

22 Commits

Author SHA1 Message Date
zhengzhijie
0f238c25e0 docs(sheets): sync lark-sheets skill from sheet-skill-spec (add +recover)
Mirror generated/ from sheet-skill-spec: adds the +recover shortcut — skill
reference lark-sheets-recover.md + flag-defs/flag-schemas entry (方案B,
full-document rollback). Also brings this branch's lark-sheets skill in line
with the latest spec develop (workbook / write-cells reference + flag updates
larksuite-cli had not yet synced).
2026-06-12 17:12:57 +08:00
zhengzhijie
c95ab1c28e feat(sheets): add +recover shortcut (方案B, full-document revision rollback)
Splits 方案B (recover) into its own MR, shipping ahead of 方案A (undo).
+recover rolls the whole spreadsheet back to a past revision via the facade
recover_to_revision write tool; only the recover shortcut and its flag live
here. undo (方案A) and the read/write transaction_id split stay in PR #1321.
2026-06-11 21:55:06 +08:00
zhengzhijiej-tech
bed30c4ecb Merge pull request #1351 from larksuite/fix/chart-dim-insert-example
docs(sheets): chart / filter / workbook reference corrections
2026-06-09 16:47:31 +08:00
zhengzhijie
a7be567066 docs(sheets): label +sheet-create --index as 0-based
The base flag description for +sheet-create's --index omitted the
coordinate base, while its siblings +sheet-move ("Target position
(0-based)") and +sheet-copy already state 0-based. Align the description
so the index base is unambiguous. Synced from the spec source
(flag-defs.json + workbook reference).
2026-06-09 16:25:02 +08:00
zhengzhijie
e96acad2c5 docs(sheets): chart coordinate base / quoting + filter condition enums
Sync three reference-doc corrections from the spec source:

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

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

3. filter / filter-view: add the full conditions[].type x compare_type
   enum table (text / number / multiValue / color and their respective
   compare_type values and values shape), and call out the
   equals/notEquals (with s) vs equal/notEqual (no s) gotcha. The docs
   previously only showed two values via examples.
2026-06-09 16:25:02 +08:00
zhengzhijie
7ac8a7d30e docs(sheets): fix invalid +dim-insert example in chart reference
The chart reference's placement example used non-existent flags
--dimension/--start/--end for +dim-insert. The real signature is
--position (required) + --count (required); copying the example
fails Validate with "--position is required". Replace it with
+dim-insert --position V --count 6 (insert 6 columns before V,
i.e. after U), aligning with the sheet-structure reference.
2026-06-09 15:34:05 +08:00
xiongyuanwen-byted
31523b7f50 docs(sheets): align +csv-put help with formula support
Sync the formula-support wording from sheet-skill-spec (flag-defs, skill
references) and update the hand-authored cobra Description and comment for
+csv-put. +csv-put evaluates a leading-= cell as a formula via
set_range_from_csv; descriptions only, no behavior change.
2026-06-08 20:38:10 +08:00
zhengzhijiej-tech
02a37029c2 Merge pull request #1296 from larksuite/feat/sheet-eval-guidance-fixes
docs(sheets): strengthen lark-sheets references for common editing pitfalls
2026-06-08 19:13:29 +08:00
zhengzhijie
556d7e3a77 docs(sheets): align write-cells reference with the generated output
Bring the hand-applied write-cells example in line with the spec-generated
reference so the CLI mirror is byte-identical to the canonical source.
2026-06-08 19:07:44 +08:00
Chenweifeng-bd
f18a082a4f docs: add lark sheets financial modeling guidance 2026-06-08 17:05:11 +08:00
zhengzhijie
b8c5176483 docs(sheets): reword guidance to avoid eval-specific phrasing
Replace scoring-framework wording in the examples with plain functional
consequences (e.g. "not delivered", "goes stale when the source changes",
"breaks the original visual format"), so the references stay agent-facing.
2026-06-08 15:44:35 +08:00
zhengzhijie
82937a0a37 docs(sheets): keep original column widths; align chart axis with requested metric
- range-operations: only widen new / overflowing columns; never recompute or
  shrink the widths of existing columns (any blanket resize, even by 1px,
  breaks the original visual format)
- chart: when the user asks for a share / percentage, the value axis should be
  a percentage (pie, or stack.percentage on bar/column) rather than raw counts
2026-06-08 14:38:00 +08:00
xiongyuanwen-byted
1cafb94a62 refactor(sheets): reuse the drive export core in +workbook-export
Replace +workbook-export's parallel export-task implementation with the shared drive ExportParams/RunExport core (pinned to type=sheet). Drops ~90 lines of duplicated poll/download code; +workbook-export now inherits drive's ctx cancellation, resume-on-timeout, filename sanitize/overwrite, and the full set of export status labels. The output contract aligns with drive's (adds ready/downloaded/doc_type; saved_path preserved). Also normalize an empty drive --output-dir to "." so drive +export behavior is unchanged, and fix the sheets export e2e to call +workbook-export instead of a nonexistent +export.
2026-06-08 12:58:11 +08:00
xiongyuanwen-byted
0b33daa136 feat(sheets): add +workbook-import wrapping the drive import core
Import a local xlsx/xls/csv as a new spreadsheet by delegating to the shared drive import flow with the target type pinned to sheet. Refactor drive +import to expose ImportParams / ValidateImport / PlanImportDryRun / RunImport (behavior unchanged, existing drive tests still cover it); sheets reuses them. Regenerate flag_defs_gen.go and sync the spec mirror.
2026-06-08 11:00:46 +08:00
xiongyuanwen-byted
5a61b97ac3 docs(sheets): sync SKILL.md (drop "Feishu sheets only" caveat)
Mirror the upstream sheet-skill-spec change removing the "applies to Feishu sheets only" tail from the 14 sheet reference descriptions.
2026-06-07 22:45:53 +08:00
xiongyuanwen-byted
e01f2dfdd5 docs(sheets): sync SKILL.md (drop "not for local Excel" caveat)
Mirror the upstream sheet-skill-spec change removing the "not applicable to local Excel files" tail from the sheets skill and reference descriptions.
2026-06-07 22:39:58 +08:00
xiongyuanwen-byted
45f807459e docs(sheets): surface typed-write path at the write-decision point
Quick-ref table (SKILL.md, the first decision point) had no +table-put and
gated typed writes on "DataFrame", so a model holding a Counter/list/dict
would fall back to +csv-put and silently lose number/date fidelity.

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

Synced from sheet-skill-spec canonical (generate:cli + sync:cli).
2026-06-07 00:30:13 +08:00
xiongyuanwen-byted
8906e87fb1 feat(sheets): implement table-put/table-get and sync skill specs
- Add lark_sheet_table_io.go with +table-put / +table-get and tests
- Refactor read-data; extend workbook; register new shortcuts
- Sync generated flag defs/schemas (go:embed) from sheet-skill-spec
- Sync skill references (write-cells numeric-column guidance, plus
  read-data / workbook / chart updates)
2026-06-05 20:03:33 +08:00
zhengzhijie
d5a53d921d docs(sheets): strengthen lark-sheets references for common editing pitfalls
Add targeted guidance to six lark-sheets references to reduce frequent
mistakes when editing spreadsheets through the CLI:

- write-cells: sanity-check units / dimension conversion / quantity factors
  before formula writes (formulas can run clean yet be off by a factor);
  keep derived output off original data columns to avoid clobbering source
- core-operations: prefer live formulas for derived values even when "live
  update" is not explicitly requested; scope rewrite/transform precisely so
  rows/columns that should stay unchanged are kept 1:1; treat header-stated
  format rules as checklist items; confirm the artifact file actually exists
  before finishing; write back bare values from local scripts
- visual-standards: apply border/header formatting on explicit request and
  identify the real header row; keep font size consistent with the source
- range-operations: keep total column width within A4 for printing
- read-data: dedup/compare long numbers via raw values, not csv formatted
  display (scientific notation collapses distinct numbers and causes false
  duplicates)
- chart: format date/number axes via source-cell number_format; place charts
  outside the data area so they do not cover existing data
2026-06-05 19:20:25 +08:00
zhengzhijiej-tech
0ff7f0407e Merge pull request #1264 from zhengzhijiej-tech/feat/sheet-gridline
feat(sheets): add gridline show/hide shortcuts
2026-06-04 19:12:41 +08:00
YH-1600
c000dc3a44 docs: refine lark-drive knowledge organize workflow (#1253)
Change-Id: I49b4f398d60c5bb073d6c8d61987bd16f1a29c4e
2026-06-04 15:31:46 +08:00
zhicong666-bytedance
256df8c0fb docs(vc-agent): require explicit leave request (#1260) 2026-06-04 14:33:57 +08:00
41 changed files with 4844 additions and 1046 deletions

View File

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

View File

@@ -488,6 +488,72 @@ func TestDriveExportAsyncSuccess(t *testing.T) {
}
}
// TestDriveExportEmptyOutputDirDownloadsToCwd guards the export refactor: an
// explicit empty --output-dir must still download to the current directory
// (normalized to "."), not trigger the export-only no-download path that the
// shared RunExport core uses for sheets +workbook-export.
func TestDriveExportEmptyOutputDirDownloadsToCwd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"ticket": "tk_e"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_e",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"result": map[string]interface{}{
"job_status": 0, "file_token": "box_e", "file_name": "report",
"file_extension": "pdf", "type": "docx", "file_size": 3,
},
}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/file/box_e/download",
Status: 200,
RawBody: []byte("pdf"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
"Content-Disposition": []string{`attachment; filename="report.pdf"`},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
prevAttempts, prevInterval := driveExportPollAttempts, driveExportPollInterval
driveExportPollAttempts, driveExportPollInterval = 1, 0
t.Cleanup(func() {
driveExportPollAttempts, driveExportPollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveExport, []string{
"+export",
"--token", "docx123",
"--doc-type", "docx",
"--file-extension", "pdf",
"--output-dir", "",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Empty --output-dir must still write to cwd, not skip the download.
data, err := os.ReadFile(filepath.Join(tmpDir, "report.pdf"))
if err != nil {
t.Fatalf("empty --output-dir should still download to cwd: %v", err)
}
if string(data) != "pdf" {
t.Fatalf("downloaded content = %q", string(data))
}
if strings.Contains(stdout.String(), `"downloaded": false`) {
t.Fatalf("export-only path must not trigger for drive +export: %s", stdout.String())
}
}
func TestDriveExportAsyncUsesProvidedFileName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

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

View File

@@ -54,7 +54,7 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Insert position; appended to the end when omitted",
"desc": "Insert position (0-based); appended to the end when omitted",
"default": "-1"
},
{
@@ -511,22 +511,33 @@
"desc": "Target folder token; placed at the drive root when omitted"
},
{
"name": "headers",
"name": "values",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Header row as a JSON array: `[\"Col A\",\"Col B\"]`",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [
"file",
"stdin"
]
},
{
"name": "values",
"name": "sheets",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial data as a 2D JSON array: `[[\"alice\",95]]`",
"desc": "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).",
"input": [
"file",
"stdin"
]
},
{
"name": "styles",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).",
"input": [
"file",
"stdin"
@@ -593,6 +604,32 @@
}
]
},
"+workbook-import": {
"risk": "write",
"flags": [
{
"name": "file",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Local file path (.xlsx / .xls / .csv)"
},
{
"name": "folder-token",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Target folder token; imported to the cloud drive root when omitted"
},
{
"name": "name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Imported spreadsheet name; defaults to the local file name without its extension"
}
]
},
"+sheet-info": {
"risk": "read",
"flags": [
@@ -1292,19 +1329,65 @@
"desc": "Skip hidden rows and columns; default `false`"
},
{
"name": "rows-json",
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
}
]
},
"+table-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by id); omit to read all sheets"
},
{
"name": "sheet-name",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Read only this sheet (by name); omit to read all sheets"
},
{
"name": "range",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "A1 range to read; omit to read each sheet current region"
},
{
"name": "no-header",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false",
"default": "false"
"desc": "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request path and parameters without executing"
"desc": ""
}
]
},
@@ -1929,7 +2012,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "RFC 4180 CSV text; plain values only (no formulas / styles / comments)",
"desc": "RFC 4180 CSV text; values or formulas (a leading = is evaluated as a formula); no styles / comments / images (use +cells-set for those).",
"input": [
"file",
"stdin"
@@ -1960,6 +2043,43 @@
}
]
},
"+table-put": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token to write into (XOR with `--url`)"
},
{
"name": "sheets",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool",
"input": [
"file",
"stdin"
]
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+cells-clear": {
"risk": "high-risk-write",
"flags": [
@@ -4618,5 +4738,38 @@
"desc": ""
}
]
},
"+recover": {
"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": "to-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Restore the whole spreadsheet to this revision (a revision number returned by a prior write)"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
}
}

View File

@@ -1730,11 +1730,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -1787,11 +1788,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+chart-update": {
@@ -2769,11 +2766,12 @@
},
"aggregateType": {
"type": "string",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效",
"description": "汇总方式,默认为'sum',仅在 aggregate 为 true 时生效。count 只统计数值单元格counta 统计所有非空单元格(含文本),按文本/分类列统计出现次数(如各类别的数量、频次分布)时用 counta。",
"enum": [
"sum",
"average",
"count",
"counta",
"min",
"max",
"median"
@@ -2826,11 +2824,7 @@
"data"
]
}
},
"required": [
"position",
"size"
]
}
}
},
"+cond-format-create": {
@@ -6249,6 +6243,477 @@
}
}
}
},
"+table-put": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成NaN→null、Timestamp→ISO、numpy 标量→原生)。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"rows"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列定义,顺序与 rows 中每行的取值一一对应。",
"items": {
"type": "object",
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"description": "列名(写入表头行的文本)。"
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"date",
"bool"
],
"description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字。string 列由 +table-put 自动套文本格式number_format `@`数字样字符串含前导零如「00123」读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串CLI 转成 Excel 序列号 + 日期 number_format真日期可排序 / 透视 / 筛选)。"
},
"format": {
"type": "string",
"description": "可选的单元格 number_format如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责0.0469 配 \"0.00%\"helper 不自动乘 100。"
}
}
}
},
"rows": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值,按所在列的 type 取值string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。"
}
}
}
}
}
}
},
"+workbook-create": {
"sheets": {
"type": "array",
"minItems": 1,
"description": "一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入。可由 pandas DataFrame 经薄 helper 生成NaN→null、Timestamp→ISO、numpy 标量→原生)。",
"items": {
"type": "object",
"required": [
"name",
"columns",
"rows"
],
"properties": {
"name": {
"type": "string",
"description": "目标子表名。按名匹配已有子表;不存在则新建该子表。同一次调用内子表名不可重复。"
},
"start_cell": {
"type": "string",
"default": "A1",
"description": "写入起点单元格A1 记法,如 \"B2\"),默认 \"A1\"。mode=append 时忽略其行号、仅沿用其列。"
},
"mode": {
"type": "string",
"enum": [
"overwrite",
"append"
],
"default": "overwrite",
"description": "overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头。"
},
"header": {
"type": "boolean",
"description": "是否写一行列名表头。省略时按 mode 取默认overwrite→true、append→false避免在已有表头下重复显式给值可覆盖。"
},
"allow_overwrite": {
"type": "boolean",
"default": true,
"description": "为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success。默认 true。"
},
"columns": {
"type": "array",
"minItems": 1,
"description": "列定义,顺序与 rows 中每行的取值一一对应。",
"items": {
"type": "object",
"required": [
"name",
"type"
],
"properties": {
"name": {
"type": "string",
"description": "列名(写入表头行的文本)。"
},
"type": {
"type": "string",
"enum": [
"string",
"number",
"date",
"bool"
],
"description": "列的声明类型,显式声明、不由 CLI 猜测(避免邮编 / 订单号 / 手机号等「像数字的文本」被误判为数字。string 列由 +table-put 自动套文本格式number_format `@`数字样字符串含前导零如「00123」读写两侧都保真——+table-get 读回时仍判为 string、不会塌缩成数字。date 列取 ISO yyyy-mm-dd 字符串CLI 转成 Excel 序列号 + 日期 number_format真日期可排序 / 透视 / 筛选)。"
},
"format": {
"type": "string",
"description": "可选的单元格 number_format如 \"yyyy-mm-dd\" / \"0.00%\" / \"#,##0.00\"。percent 列的数值尺度由调用方负责0.0469 配 \"0.00%\"helper 不自动乘 100。"
}
}
}
},
"rows": {
"type": "array",
"description": "数据行;每行是一个数组,长度必须等于 columns 数。元素按对应列的类型取值null 表示空单元格。",
"items": {
"type": "array",
"items": {
"type": [
"string",
"number",
"boolean",
"null"
],
"description": "单元格值,按所在列的 type 取值string→文本 / number→数值 / date→ISO yyyy-mm-dd 文本 / bool→布尔null 表示空单元格。具体类型由该列在 columns 里声明的 type 决定,故此处仅约束为标量或 null。"
}
}
}
}
}
},
"styles": {
"items": {
"properties": {
"cell_merges": {
"description": "单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all。",
"items": {
"properties": {
"merge_type": {
"enum": [
"all",
"rows",
"columns"
],
"type": "string"
},
"range": {
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"cell_styles": {
"description": "单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐。",
"items": {
"properties": {
"background_color": {
"type": "string"
},
"border_styles": {
"type": "object",
"description": "边框配置,结构同 +cells-set-style --border-styles。",
"properties": {
"bottom": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"left": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"right": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
},
"top": {
"properties": {
"color": {
"description": "边框颜色(十六进制,例如 \"#000000\"",
"type": "string"
},
"style": {
"description": "边框线型;传 \"none\" 表示清除该方向边框(无边框线)",
"enum": [
"solid",
"dashed",
"dotted",
"double",
"none"
],
"type": "string"
},
"weight": {
"description": "边框粗细/线宽",
"enum": [
"thin",
"medium",
"thick"
],
"type": "string"
}
},
"type": "object"
}
}
},
"font_color": {
"type": "string"
},
"font_line": {
"enum": [
"none",
"underline",
"line-through"
],
"type": "string"
},
"font_size": {
"type": "number"
},
"font_style": {
"enum": [
"normal",
"italic"
],
"type": "string"
},
"font_weight": {
"enum": [
"normal",
"bold"
],
"type": "string"
},
"horizontal_alignment": {
"enum": [
"left",
"center",
"right"
],
"type": "string"
},
"number_format": {
"type": "string"
},
"range": {
"description": "A1 单元格范围,必须落在该子表本次写入区域内;例如 A1:B1、B2。",
"type": "string"
},
"vertical_alignment": {
"enum": [
"top",
"middle",
"bottom"
],
"type": "string"
},
"word_wrap": {
"enum": [
"overflow",
"auto-wrap",
"word-clip"
],
"type": "string"
}
},
"required": [
"range"
],
"type": "object"
},
"type": "array"
},
"col_sizes": {
"description": "列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
},
"name": {
"description": "子表名。--sheets 模式下必须与同位置 --sheets.sheets[].name 一致;--values 模式下建议写 Sheet1其 name 会被忽略)。",
"type": "string"
},
"row_sizes": {
"description": "行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size。",
"items": {
"properties": {
"range": {
"type": "string"
},
"size": {
"type": "number"
},
"type": {
"enum": [
"pixel",
"standard",
"auto"
],
"type": "string"
}
},
"required": [
"range",
"type"
],
"type": "object"
},
"type": "array"
}
},
"required": [
"name"
],
"type": "object"
},
"type": "array"
}
}
}
}

View File

@@ -308,7 +308,6 @@ var flagDefs = map[string]commandDef{
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "rows-json", Kind: "own", Type: "bool", Required: "optional", Desc: "Return structured rows ({row_number, values:{col→cell}}) instead of CSV text; default false", Default: "false"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
},
},
@@ -735,6 +734,15 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+recover": {
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: "to-revision", Kind: "own", Type: "int", Required: "required", Desc: "Restore the whole spreadsheet to this revision (a revision number returned by a prior write)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
Risk: "write",
Flags: []flagDef{
@@ -915,6 +923,28 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet current region"},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+table-put": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL to write into (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token to write into (XOR with `--url`)"},
{Name: "sheets", Kind: "own", Type: "string", Required: "required", Desc: "Typed table payload as JSON: a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool", Input: []string{"file", "stdin"}},
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the header row written from column names (default true)", Default: "true"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
@@ -922,6 +952,8 @@ var flagDefs = map[string]commandDef{
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "headers", Kind: "own", Type: "string", Required: "optional", Desc: "Header row as a JSON array: `[\"Col A\",\"Col B\"]`", Input: []string{"file", "stdin"}},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Initial data as a 2D JSON array: `[[\"alice\",95]]`", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): a top-level `sheets` array, each item `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}`; column `type` is one of string/number/date/bool. Mutually exclusive with --headers/--values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "header-style", Kind: "own", Type: "bool", Required: "optional", Desc: "Bold the typed header row (only with --sheets; default true)", Default: "true"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -936,6 +968,14 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-import": {
Risk: "write",
Flags: []flagDef{
{Name: "file", Kind: "own", Type: "string", Required: "required", Desc: "Local file path (.xlsx / .xls / .csv)"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; imported to the cloud drive root when omitted"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Imported spreadsheet name; defaults to the local file name without its extension"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet_recover ───────────────────────────────────────────────
//
// Wraps:
// - recover_to_revision (write) — powers +recover
//
// Rolls the WHOLE spreadsheet back to a past revision (the undo design doc's
// "方案 B"). Unlike +undo — which is precise, per-edit, and scoped to this CLI
// link — +recover is a full-document version restore. The facade gateway
// already owns this capability (the same revert-by-revision path the web
// "history" panel drives): it submits a single RECOVER changeset that reverts
// every sheet to the target revision and produces a new revision. The CLI only
// passes the target revision; all the work stays server-side.
//
// ⚠️ Full-table overwrite: +recover discards EVERY change made after
// --to-revision, including other collaborators' (and the web UI's) edits. Use
// it only on agent scratch spreadsheets, or when a whole-document rollback is
// acceptable. For precise, this-link-only undo, use +undo instead.
var Recover = common.Shortcut{
Service: "sheets",
Command: "+recover",
Description: "Roll the whole spreadsheet back to a past revision (full-document restore; discards all later edits).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+recover"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
_, err = recoverInput(runtime, token)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
input, _ := recoverInput(runtime, token)
return invokeToolDryRun(token, ToolKindWrite, "recover_to_revision", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
input, err := recoverInput(runtime, token)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "recover_to_revision", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
Tips: []string{
"+recover is a FULL-DOCUMENT rollback — it discards every edit made after --to-revision, including other collaborators'. For precise, this-link-only undo, use +undo instead.",
"--to-revision takes a revision number returned by a prior write (the `revision` field in the response).",
"Use --dry-run to preview the recover request before running it.",
},
}
// recoverInput builds the recover_to_revision tool body. Network-free; shared
// by Validate, DryRun, and Execute.
func recoverInput(runtime flagView, token string) (map[string]interface{}, error) {
rev := runtime.Int("to-revision")
if rev < 1 {
return nil, common.FlagErrorf("--to-revision must be a positive revision number")
}
return map[string]interface{}{
"excel_id": token,
"to_revision": rev,
}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,991 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// ─── pure helpers: date serial, typed cell mapping ────────────────────
func TestTablePut_IsoDateToSerial(t *testing.T) {
t.Parallel()
cases := []struct {
in string
want int
ok bool
}{
{"2024-01-15", 45306, true}, // the empirically verified anchor
{"2024-01-01", 45292, true},
{"2024-02-29", 45351, true}, // 2024 is a leap year
{"1899-12-31", 1, true}, // one day after the epoch
{"not-a-date", 0, false},
{"2024/01/15", 0, false}, // wrong separator
}
for _, tt := range cases {
got, err := isoDateToSerial(tt.in)
if tt.ok {
if err != nil {
t.Errorf("isoDateToSerial(%q) unexpected error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("isoDateToSerial(%q) = %d, want %d", tt.in, got, tt.want)
}
} else if err == nil {
t.Errorf("isoDateToSerial(%q) = %d, want error", tt.in, got)
}
}
}
func TestTablePut_BuildTypedCell(t *testing.T) {
t.Parallel()
t.Run("string keeps literal + text format so digit-like ids survive read-back", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "id", Type: "string"}, "00123")
if err != nil {
t.Fatal(err)
}
if cell["value"] != "00123" {
t.Errorf("value = %#v, want \"00123\"", cell["value"])
}
if nf := numberFormatOf(cell); nf != "@" {
t.Errorf("number_format = %q, want @ (text format so +table-get infers string, not number)", nf)
}
})
t.Run("string stringifies a json.Number without scientific notation", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "code", Type: "string"}, json.Number("123456789012345"))
if cell["value"] != "123456789012345" {
t.Errorf("value = %#v, want literal digits", cell["value"])
}
})
t.Run("number preserves json.Number", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "amt", Type: "number", Format: "#,##0"}, json.Number("259874"))
if err != nil {
t.Fatal(err)
}
if n, ok := cell["value"].(json.Number); !ok || n.String() != "259874" {
t.Errorf("value = %#v, want json.Number 259874", cell["value"])
}
if nf := numberFormatOf(cell); nf != "#,##0" {
t.Errorf("number_format = %q, want #,##0", nf)
}
})
t.Run("date converts to serial + default format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, "2024-01-15")
if err != nil {
t.Fatal(err)
}
if cell["value"] != 45306 {
t.Errorf("value = %#v, want serial 45306", cell["value"])
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("number_format = %q, want default yyyy-mm-dd", nf)
}
})
t.Run("date honors explicit format", func(t *testing.T) {
t.Parallel()
cell, _ := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date", Format: "yyyy-mm"}, "2024-01-15")
if nf := numberFormatOf(cell); nf != "yyyy-mm" {
t.Errorf("number_format = %q, want yyyy-mm", nf)
}
})
t.Run("bool maps to boolean", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "b", Type: "bool"}, true)
if err != nil || cell["value"] != true {
t.Errorf("value = %#v (err=%v), want true", cell["value"], err)
}
})
t.Run("null is an empty cell that still carries format", func(t *testing.T) {
t.Parallel()
cell, err := buildTypedCell(&tableColumnSpec{Name: "d", Type: "date"}, nil)
if err != nil {
t.Fatal(err)
}
if _, has := cell["value"]; has {
t.Errorf("null cell should have no value: %#v", cell)
}
if nf := numberFormatOf(cell); nf != "yyyy-mm-dd" {
t.Errorf("null date cell should still carry format, got %q", nf)
}
})
t.Run("type mismatches are rejected", func(t *testing.T) {
t.Parallel()
if _, err := buildTypedCell(&tableColumnSpec{Type: "number"}, "abc"); err == nil {
t.Error("number column accepting a string should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "date"}, json.Number("1")); err == nil {
t.Error("date column accepting a number should error")
}
if _, err := buildTypedCell(&tableColumnSpec{Type: "bool"}, "true"); err == nil {
t.Error("bool column accepting a string should error")
}
})
}
// numberFormatOf digs the number_format out of a built cell's cell_styles, or
// "" when absent.
func numberFormatOf(cell map[string]interface{}) string {
styles, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
return ""
}
nf, _ := styles["number_format"].(string)
return nf
}
// ─── payload validation ───────────────────────────────────────────────
func TestTablePut_PayloadValidation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
json string
want string
}{
{"empty sheets", `{"sheets":[]}`, "at least one sheet"},
{"missing name", `{"sheets":[{"columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "name is required"},
{"duplicate name", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]},{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "duplicate sheet name"},
{"no columns", `{"sheets":[{"name":"S","columns":[],"rows":[]}]}`, "columns must be non-empty"},
{"bad column type", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"timestamp"}],"rows":[]}]}`, "invalid type"},
{"column missing name", `{"sheets":[{"name":"S","columns":[{"type":"string"}],"rows":[]}]}`, "columns[0].name is required"},
{"row width mismatch", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["x"]]}]}`, "column count"},
{"bad start_cell", `{"sheets":[{"name":"S","start_cell":"A","columns":[{"name":"a","type":"string"}],"rows":[]}]}`, "start_cell"},
{"bad date value", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2025/03/31"]]}]}`, "must be ISO"},
{"number expects numeric", `{"sheets":[{"name":"S","columns":[{"name":"n","type":"number"}],"rows":[["abc"]]}]}`, "number expects"},
{"invalid json", `{not json`, "invalid JSON"},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": tt.json})
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Errorf("want error containing %q, got %v", tt.want, err)
}
})
}
}
// stubFlagView is a minimal flagView backed by a map, for unit-testing the
// payload parser without a cobra command.
type stubFlagView map[string]string
func (s stubFlagView) Str(name string) string { return s[name] }
func (s stubFlagView) Bool(name string) bool { return s[name] == "true" }
func (s stubFlagView) Int(name string) int { return 0 }
func (s stubFlagView) Float64(name string) float64 { return 0 }
func (s stubFlagView) Changed(name string) bool { _, ok := s[name]; return ok }
func (s stubFlagView) StrArray(name string) []string { return nil }
func (s stubFlagView) StrSlice(name string) []string { return nil }
func (s stubFlagView) Command() string { return "+table-put" }
// ─── dry-run: create + write rendering ────────────────────────────────
const tablePutSheetsJSON = `{"sheets":[{"name":"月度","columns":[` +
`{"name":"门店","type":"string"},` +
`{"name":"月份","type":"date","format":"yyyy-mm"},` +
`{"name":"销售额","type":"number","format":"#,##0"}` +
`],"rows":[["北京","2024-01-15",259874]]}]}`
func TestTablePut_DryRunWrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets", tablePutSheetsJSON})
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (set_cell_range only)", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["excel_id"] != testToken {
t.Errorf("excel_id = %v, want %s", input["excel_id"], testToken)
}
if input["sheet_name"] != "月度" {
t.Errorf("sheet_name = %v, want 月度", input["sheet_name"])
}
if input["range"] != "A1:C2" {
t.Errorf("range = %v, want A1:C2 (1 header + 1 data row × 3 cols)", input["range"])
}
rows := input["cells"].([]interface{})
header := rows[0].([]interface{})
if hs := cellStyles(header[0]); hs["font_weight"] != "bold" {
t.Errorf("header cell should be bold, got %#v", header[0])
}
data := rows[1].([]interface{})
// 月份 (date) → serial 45306, number_format yyyy-mm
if v := cellValue(data[1]); v != float64(45306) {
t.Errorf("date cell value = %#v, want 45306 serial", v)
}
if nf := cellStyles(data[1])["number_format"]; nf != "yyyy-mm" {
t.Errorf("date number_format = %v, want yyyy-mm", nf)
}
// 销售额 (number) → 259874 preserved
if v := cellValue(data[2]); v != float64(259874) {
t.Errorf("number cell value = %#v, want 259874", v)
}
}
func cellValue(c interface{}) interface{} {
m, _ := c.(map[string]interface{})
return m["value"]
}
func cellStyles(c interface{}) map[string]interface{} {
m, _ := c.(map[string]interface{})
s, _ := m["cell_styles"].(map[string]interface{})
return s
}
// ─── validation through the cobra surface ─────────────────────────────
func TestTablePut_Validation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
args []string
want string
}{
{
name: "missing spreadsheet locator rejected",
args: []string{"--sheets", tablePutSheetsJSON},
want: "at least one",
},
{
name: "url and token are mutually exclusive",
args: []string{"--url", testURL, "--spreadsheet-token", testToken, "--sheets", tablePutSheetsJSON},
want: "mutually exclusive",
},
{
name: "bad column type rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"foo"}],"rows":[]}]}`},
want: "invalid type",
},
{
name: "row width mismatch rejected",
args: []string{"--url", testURL, "--sheets", `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"},{"name":"b","type":"string"}],"rows":[["only-one"]]}]}`},
want: "column count",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, TablePut, append(tt.args, "--dry-run"))
if err == nil {
t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr)
}
if !strings.Contains(stdout+stderr+err.Error(), tt.want) {
t.Errorf("error missing %q; got=%s|%s|%v", tt.want, stdout, stderr, err)
}
})
}
}
// ─── execute paths with stubbed tools ─────────────────────────────────
// TestTablePut_ExecuteWrite drives the write path: a structure read maps the
// existing sheet by name, then a set_cell_range write fills it.
func TestTablePut_ExecuteWrite(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
write := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"},{"name":"b","type":"number"}],"rows":[["x",1]]}]}`},
structure, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1: %#v", len(sheets), data)
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "数据" || s0["sheet_id"] != testSheetID {
t.Errorf("sheet summary = %#v, want name=数据 sheet_id=%s", s0, testSheetID)
}
if s0["range"] != "A1:B2" {
t.Errorf("range = %v, want A1:B2", s0["range"])
}
}
// TestTablePut_ExecuteWriteCreatesMissingSheet covers the branch where the
// named sheet does not yet exist: a create precedes the write.
func TestTablePut_ExecuteWriteCreatesMissingSheet(t *testing.T) {
t.Parallel()
// First structure read sees only "Sheet1"; the payload targets "新表", so
// createSheet runs, and the follow-up read (FIFO: second stub) resolves the
// newly created sheet's id.
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"新表","index":1}]}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
write.Reusable = true // modify_workbook_structure create + set_cell_range
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新表","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
structBefore, structAfter, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("result sheets = %d, want 1", len(sheets))
}
if s0, _ := sheets[0].(map[string]interface{}); s0["sheet_id"] != testSheetID2 {
t.Errorf("created sheet id = %v, want %s", s0["sheet_id"], testSheetID2)
}
}
// TestTablePut_SheetCreateDims checks new-sheet sizing: small tables keep the
// 20×200 floor (unchanged behavior), wide/long tables grow past it (the fix for
// set_cell_range "exceeds sheet bounds"), and start_cell offset + header row are
// accounted for, with columns clamped to the backend's 200 ceiling.
func TestTablePut_SheetCreateDims(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
cols := func(n int) []tableColumnSpec { return make([]tableColumnSpec, n) }
rows := func(n int) [][]interface{} { return make([][]interface{}, n) }
cases := []struct {
name string
spec tableSheetSpec
wantRows, wantCols int
}{
{"small table keeps 20x200 floor", tableSheetSpec{Columns: cols(3), Rows: rows(5)}, 200, 20},
{"wide table grows columns", tableSheetSpec{Columns: cols(37), Rows: rows(22)}, 200, 37},
{"long table grows rows", tableSheetSpec{Columns: cols(3), Rows: rows(500)}, 501, 20},
{"start_cell offset adds to both", tableSheetSpec{StartCell: "C5", Columns: cols(40), Rows: rows(5)}, 200, 42},
{"header:false drops the header row", tableSheetSpec{Header: bp(false), Columns: cols(3), Rows: rows(500)}, 500, 20},
{"columns clamp at backend max 200", tableSheetSpec{Columns: cols(250), Rows: rows(5)}, 200, 200},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotRows, gotCols := sheetCreateDims(&tt.spec)
if gotRows != tt.wantRows || gotCols != tt.wantCols {
t.Errorf("sheetCreateDims = (%d rows, %d cols), want (%d, %d)", gotRows, gotCols, tt.wantRows, tt.wantCols)
}
})
}
}
// TestTablePut_ExecuteCreatesWideSheetWithDims is the regression test for the
// wide-table bug: a 25-column payload targeting a not-yet-existing sheet must
// create it with 25 columns (past the 20-column default) so the follow-up
// set_cell_range fits instead of failing with "exceeds sheet bounds".
func TestTablePut_ExecuteCreatesWideSheetWithDims(t *testing.T) {
t.Parallel()
structBefore := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0}]}`)
createStub := toolOutputStub(testToken, "write", `{"ok":true}`) // modify_workbook_structure create
structAfter := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"Sheet1","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"宽表","index":1}]}`)
writeStub := toolOutputStub(testToken, "write", `{"ok":true}`) // set_cell_range
const n = 25
cols := strings.TrimRight(strings.Repeat(`{"name":"c","type":"string"},`, n), ",")
vals := strings.TrimRight(strings.Repeat(`"x",`, n), ",")
payload := `{"sheets":[{"name":"宽表","columns":[` + cols + `],"rows":[[` + vals + `]]}]}`
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets", payload},
structBefore, createStub, structAfter, writeStub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &wire); err != nil {
t.Fatalf("decode create body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode create tool input: %v", err)
}
if input["operation"] != "create" {
t.Fatalf("first write should be the create op, got %#v", input["operation"])
}
if input["columns"] != float64(n) {
t.Errorf("create columns = %#v, want %d (sized to the wide payload)", input["columns"], n)
}
if input["rows"] != float64(200) {
t.Errorf("create rows = %#v, want 200 (floor)", input["rows"])
}
}
// TestTablePut_ExecutePartialFailure covers the partial-success error path:
// a set_cell_range write fails mid-import and the structured error surfaces.
// TestTablePut_ExecuteTotalFailure: a single sheet whose write fails landed
// nothing — it must be a plain failure, NOT partial_success.
func TestTablePut_ExecuteTotalFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"数据","index":0}]}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"数据","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`},
structure, writeErr)
if err == nil {
t.Fatalf("expected failure; got nil. out=%s", out)
}
if strings.Contains(err.Error(), "partially applied") || strings.Contains(out, "partially applied") {
t.Errorf("single-sheet failure must NOT be partial_success; got err=%v out=%s", err, out)
}
if !strings.Contains(err.Error(), "failed") && !strings.Contains(out, "no sheets were written") {
t.Errorf("expected plain-failure message; got err=%v out=%s", err, out)
}
}
// TestTablePut_ExecutePartialFailure: first sheet's write lands, second fails →
// partial_success carrying the first sheet in written_sheets.
func TestTablePut_ExecutePartialFailure(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read",
`{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"汇总","index":0},{"sheet_id":"`+testSheetID2+`","sheet_name":"明细","index":1}]}`)
writeOK := toolOutputStub(testToken, "write", `{"updated_cells_count":2}`)
writeErr := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_write",
Body: map[string]interface{}{"code": 1254000, "msg": "boom"},
}
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"汇总","columns":[{"name":"a","type":"string"}],"rows":[["x"]]},{"name":"明细","columns":[{"name":"a","type":"string"}],"rows":[["y"]]}]}`},
structure, writeOK, writeErr)
if err == nil {
t.Fatalf("expected partial-success error; got nil. out=%s", out)
}
if !strings.Contains(err.Error(), "partially applied") && !strings.Contains(out, "partially applied") {
t.Errorf("expected partial_success (not total failure); got err=%v out=%s", err, out)
}
// The failing sheet is named in the message; the written one lives in the
// structured written_sheets detail.
if !strings.Contains(err.Error(), "明细") {
t.Errorf("partial_success should name the failed sheet 明细; got err=%v", err)
}
}
// ─── +workbook-create typed --sheets path ─────────────────────────────
// TestWorkbookCreate_TypedMutualExclusion locks the Validate contract: the typed
// --sheets entry can't be combined with the untyped --headers/--values.
func TestWorkbookCreate_TypedMutualExclusion(t *testing.T) {
t.Parallel()
typed := `{"sheets":[{"name":"S","columns":[{"name":"a","type":"string"}],"rows":[["x"]]}]}`
for _, tc := range []struct {
name string
args []string
}{
{"sheets+headers", []string{"--title", "X", "--sheets", typed, "--headers", `["a"]`}},
{"sheets+values", []string{"--title", "X", "--sheets", typed, "--values", `[["x"]]`}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, tc.args)
if err == nil {
t.Fatalf("expected mutual-exclusion error; got nil (stderr=%s)", stderr)
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("want 'mutually exclusive' error; got %v", err)
}
})
}
}
// TestWorkbookCreate_EmptySheetsErrors locks the fix for an explicitly-given but
// empty --sheets (e.g. empty stdin / file): it must error, not silently fall
// through to creating an empty workbook.
func TestWorkbookCreate_EmptySheetsErrors(t *testing.T) {
t.Parallel()
_, stderr, err := runShortcutCapturingErr(t, WorkbookCreate, []string{"--title", "X", "--sheets", ""})
if err == nil {
t.Fatalf("expected error for empty --sheets; got nil (stderr=%s)", stderr)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("want 'empty' error; got %v", err)
}
}
// TestWorkbookCreate_TypedAdoptsDefaultSheet covers the one-step typed create:
// the new workbook's default sheet is renamed to the first payload sheet's name
// and reused (no empty Sheet1 left behind), then written type-faithfully (the
// date lands as an Excel serial, not text).
func TestWorkbookCreate_TypedAdoptsDefaultSheet(t *testing.T) {
t.Parallel()
create := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtTYPED", "title": "Demo"},
},
},
}
// lookupFirstSheetID and writeTypedSheets' listSheetIDsByName both read the
// structure; one reusable stub serves both, reporting only the default sheet.
structure := toolOutputStub("shtTYPED", "read", `{"sheets":[{"sheet_id":"shtDef","sheet_name":"Sheet1","index":0}]}`)
structure.Reusable = true
rename := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "modify_workbook_structure") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"ok":true}`}},
}
write := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/shtTYPED/tools/invoke_write",
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), "set_cell_range") },
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"output": `{"updated_cells_count":4}`}},
}
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"Sales","columns":[{"name":"d","type":"date"},{"name":"amt","type":"number"}],"rows":[["2024-01-15",1234.5]]}]}`,
}, create, structure, rename, write)
if err != nil {
t.Fatalf("typed create failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtTYPED" {
t.Errorf("spreadsheet_token = %v, want shtTYPED", data["spreadsheet"])
}
if sheets, _ := data["sheets"].([]interface{}); len(sheets) != 1 {
t.Fatalf("want 1 written sheet, got %#v", data["sheets"])
}
// Default sheet adopted: rename targets shtDef → "Sales" (no new sheet, no
// stray Sheet1).
renameInput := decodeToolInput(t, decodeRawEnvelopeBody(t, rename.CapturedBody), "modify_workbook_structure")
if renameInput["operation"] != "rename" || renameInput["sheet_id"] != "shtDef" || renameInput["new_name"] != "Sales" {
t.Errorf("rename should adopt default shtDef→Sales; got %#v", renameInput)
}
// The data write carries the date as serial 45306, proving the type-faithful path.
writeInput := decodeToolInput(t, decodeRawEnvelopeBody(t, write.CapturedBody), "set_cell_range")
cellsJSON, _ := json.Marshal(writeInput["cells"])
if !strings.Contains(string(cellsJSON), "45306") {
t.Errorf("date 2024-01-15 should be written as serial 45306; cells=%s", cellsJSON)
}
}
// TestWorkbookCreate_TypedDryRun verifies the dry-run previews create + a typed
// set_cell_range write with the date already converted to a serial.
func TestWorkbookCreate_TypedDryRun(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Demo",
"--sheets", `{"sheets":[{"name":"S","columns":[{"name":"d","type":"date"}],"rows":[["2024-01-15"]]}]}`,
})
if len(calls) != 2 {
t.Fatalf("want 2 dry-run calls (create + typed write), got %d", len(calls))
}
raw, _ := json.Marshal(calls[1])
if !strings.Contains(string(raw), "45306") {
t.Errorf("typed dry-run write should contain serial 45306; got %s", raw)
}
}
func TestTablePut_StringifyCellValue(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"plain", "plain"},
{json.Number("12345678901234"), "12345678901234"},
{true, "TRUE"},
{false, "FALSE"},
{3.5, "3.5"},
}
for _, tt := range cases {
if got := stringifyCellValue(tt.in); got != tt.want {
t.Errorf("stringifyCellValue(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_DescribeJSONType(t *testing.T) {
t.Parallel()
cases := []struct {
in interface{}
want string
}{
{"x", "a string"},
{json.Number("1"), "a number"},
{true, "a boolean"},
{[]interface{}{}, "an array"},
{map[string]interface{}{}, "an object"},
{3.14, "float64"},
}
for _, tt := range cases {
if got := describeJSONType(tt.in); got != tt.want {
t.Errorf("describeJSONType(%#v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestTablePut_HeaderAndMode(t *testing.T) {
t.Parallel()
bp := func(b bool) *bool { return &b }
// headerOn: overwrite writes header, append omits it by default, explicit wins
if !headerOn(&tableSheetSpec{}) {
t.Error("overwrite default should write header")
}
if headerOn(&tableSheetSpec{Mode: "append"}) {
t.Error("append should omit header by default")
}
if !headerOn(&tableSheetSpec{Mode: "append", Header: bp(true)}) {
t.Error("explicit header:true should override append default")
}
if headerOn(&tableSheetSpec{Header: bp(false)}) {
t.Error("explicit header:false should be honored")
}
// writeModeName
if writeModeName(&tableSheetSpec{}) != "overwrite" || writeModeName(&tableSheetSpec{Mode: "append"}) != "append" {
t.Error("writeModeName normalization wrong")
}
// buildSheetMatrix header toggle
s := &tableSheetSpec{Columns: []tableColumnSpec{{Name: "a", Type: "string"}}, Rows: [][]interface{}{{"x"}}}
if m, _ := buildSheetMatrix(s, true, false); len(m) != 1 {
t.Errorf("header off → 1 data row, got %d", len(m))
}
if m, _ := buildSheetMatrix(s, true, true); len(m) != 2 {
t.Errorf("header on → header + 1 data row, got %d", len(m))
}
}
func TestTablePut_BadModeRejected(t *testing.T) {
t.Parallel()
_, err := parseTablePutPayload(stubFlagView{"sheets": `{"sheets":[{"name":"S","mode":"upsert","columns":[{"name":"a","type":"string"}],"rows":[]}]}`})
if err == nil || !strings.Contains(err.Error(), "invalid") {
t.Errorf("mode \"upsert\" should be rejected, got %v", err)
}
}
// TestTablePut_AppendEmptySheetWritesHeader: appending to an EMPTY sheet still
// writes the header row, so column names aren't lost (and a later +table-get
// won't consume the first data row as the header).
func TestTablePut_AppendEmptySheetWritesHeader(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"新","index":0}]}`)
region := toolOutputStub(testToken, "read", `{}`) // empty sheet: no current_region → lastRow 0
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"新","mode":"append","columns":[{"name":"列A","type":"string"}],"rows":[["x"],["y"]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Fatalf("empty-sheet append should write header + 2 data rows = 3, got %d", len(cells))
}
if header, _ := cells[0].([]interface{}); len(header) > 0 {
if h0, _ := header[0].(map[string]interface{}); h0["value"] != "列A" {
t.Errorf("first row should be the header 列A; got %#v", h0)
}
}
if input["range"] != "A1:A3" {
t.Errorf("range = %v, want A1:A3 (header + 2 rows at top of empty sheet)", input["range"])
}
}
// TestTablePut_ExecuteAppend verifies append placement: data lands below the
// sheet's existing data (current_region A1:B5 → start at row 6) with no repeated
// header.
func TestTablePut_ExecuteAppend(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"`+testSheetID+`","sheet_name":"日志","index":0}]}`)
region := toolOutputStub(testToken, "read", `{"current_region":"A1:B5","actual_range":"A1:B5"}`)
write := toolOutputStub(testToken, "write", `{"ok":true}`)
out, err := runShortcutWithStubs(t, TablePut,
[]string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"日志","mode":"append","columns":[{"name":"时间","type":"string"},{"name":"值","type":"number"}],"rows":[["t1",1],["t2",2]]}]}`},
structure, region, write)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// inspect the set_cell_range request the append produced
var wire map[string]interface{}
if err := json.Unmarshal(write.CapturedBody, &wire); err != nil {
t.Fatalf("decode captured write body: %v", err)
}
var input map[string]interface{}
if err := json.Unmarshal([]byte(wire["input"].(string)), &input); err != nil {
t.Fatalf("decode tool input: %v", err)
}
if input["range"] != "A6:B7" {
t.Errorf("append range = %v, want A6:B7 (2 rows below last data row 5, no header)", input["range"])
}
if cells, _ := input["cells"].([]interface{}); len(cells) != 2 {
t.Errorf("append should write 2 data rows (no header), got %d", len(cells))
}
data := decodeEnvelopeData(t, out)
if s0, _ := data["sheets"].([]interface{})[0].(map[string]interface{}); s0["mode"] != "append" {
t.Errorf("summary mode = %v, want append", s0["mode"])
}
}
// TestTablePut_HeaderFalseAndAllowOverwrite checks header:false drops the
// header row and allow_overwrite:false reaches the tool input.
func TestTablePut_HeaderFalseAndAllowOverwrite(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TablePut, []string{"--url", testURL, "--sheets",
`{"sheets":[{"name":"S","header":false,"allow_overwrite":false,"columns":[{"name":"a","type":"string"}],"rows":[["x"],["y"]]}]}`})
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
if input["allow_overwrite"] != false {
t.Errorf("allow_overwrite = %v, want false", input["allow_overwrite"])
}
rows, _ := input["cells"].([]interface{})
if len(rows) != 2 {
t.Fatalf("header:false → 2 data rows only, got %d", len(rows))
}
first, _ := rows[0].([]interface{})[0].(map[string]interface{})
if first["value"] != "x" {
t.Errorf("header:false first cell = %v, want data 'x' (no header row)", first["value"])
}
}
// ─── +table-get ───────────────────────────────────────────────────────
func TestTableGet_SerialRoundTrip(t *testing.T) {
t.Parallel()
for _, iso := range []string{"2024-01-15", "2024-02-29", "2000-01-01", "1899-12-31"} {
s, err := isoDateToSerial(iso)
if err != nil {
t.Fatalf("isoDateToSerial(%s): %v", iso, err)
}
if back := serialToISO(float64(s)); back != iso {
t.Errorf("roundtrip %s → %d → %s", iso, s, back)
}
}
}
func TestTableGet_IsDateNumberFormat(t *testing.T) {
t.Parallel()
for _, nf := range []string{"yyyy-mm-dd", "yyyy-mm", "yyyy/m/d", "YYYY/MM/DD"} {
if !isDateNumberFormat(nf) {
t.Errorf("%q should be a date format", nf)
}
}
for _, nf := range []string{"#,##0", "0.00", "0.00%", "@", ""} {
if isDateNumberFormat(nf) {
t.Errorf("%q should not be a date format", nf)
}
}
}
func TestTableGet_InferColumnType(t *testing.T) {
t.Parallel()
mk := func(v interface{}, nf string) map[string]interface{} {
c := map[string]interface{}{"value": v}
if nf != "" {
c["cell_styles"] = map[string]interface{}{"number_format": nf}
}
return c
}
col := func(cells ...map[string]interface{}) [][]map[string]interface{} {
rows := make([][]map[string]interface{}, len(cells))
for i, c := range cells {
rows[i] = []map[string]interface{}{c}
}
return rows
}
if typ, f := inferColumnType(col(mk(45306.0, "yyyy-mm-dd")), 0); typ != "date" || f != "yyyy-mm-dd" {
t.Errorf("date col → %s/%s", typ, f)
}
if typ, f := inferColumnType(col(mk(100.0, "#,##0")), 0); typ != "number" || f != "#,##0" {
t.Errorf("number col → %s/%s", typ, f)
}
if typ, _ := inferColumnType(col(mk(true, "")), 0); typ != "bool" {
t.Errorf("bool col → %s", typ)
}
if typ, _ := inferColumnType(col(mk("x", "")), 0); typ != "string" {
t.Errorf("string col → %s", typ)
}
// digit-like value carrying text format (@) infers as string, not number —
// this is what makes +table-put's string columns (ids/postcodes) survive read-back.
if typ, _ := inferColumnType(col(mk(123.0, "@")), 0); typ != "string" {
t.Errorf("@-format numeric-looking col → %s, want string", typ)
}
if typ, _ := inferColumnType([][]map[string]interface{}{}, 0); typ != "string" {
t.Errorf("empty col → %s (want string)", typ)
}
// 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" {
t.Errorf("mixed number+text col → %s, want string", typ)
}
// A bare number mixed into a date column must NOT stay date (would serial-
// convert the number into a bogus date) — degrades to string.
if typ, _ := inferColumnType(col(mk(45306.0, "yyyy-mm-dd"), mk(5.0, "")), 0); typ != "string" {
t.Errorf("date+bare-number col → %s, want string", typ)
}
}
func TestTableGet_CellToTyped(t *testing.T) {
t.Parallel()
mk := func(v interface{}) map[string]interface{} { return map[string]interface{}{"value": v} }
if v := cellToTyped(mk(45306.0), "date"); v != "2024-01-15" {
t.Errorf("date serial → %v, want 2024-01-15", v)
}
if v := cellToTyped(mk(100.0), "number"); v != 100.0 {
t.Errorf("number → %v", v)
}
if v := cellToTyped(mk(true), "bool"); v != true {
t.Errorf("bool → %v", v)
}
if v := cellToTyped(mk(""), "string"); v != nil {
t.Errorf("empty string → %v, want nil", v)
}
if v := cellToTyped(nil, "string"); v != nil {
t.Errorf("nil → %v, want nil", v)
}
if v := cellToTyped(mk("hi"), "string"); v != "hi" {
t.Errorf("string → %v", v)
}
}
// TestTableGet_DigitStringRoundTrip: a column +table-put wrote as string (text
// format @) reads back as string, not number — so leading-zero ids / postcodes
// survive instead of collapsing to a number.
func TestTableGet_DigitStringRoundTrip(t *testing.T) {
t.Parallel()
region := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"邮编"}],`+
`[{"value":"00123","cell_styles":{"number_format":"@"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "S"}, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
s0, _ := sheets[0].(map[string]interface{})
cols, _ := s0["columns"].([]interface{})
if c0, _ := cols[0].(map[string]interface{}); c0["type"] != "string" {
t.Errorf("@-format col 邮编 → type %v, want string", c0["type"])
}
rows, _ := s0["rows"].([]interface{})
if r0, _ := rows[0].([]interface{}); r0[0] != "00123" {
t.Errorf("value = %v, want \"00123\" (leading zero preserved)", r0[0])
}
}
// TestTableGet_ExecuteRoundTrip reads a sheet back and checks the output is the
// same typed protocol +table-put consumes: date serial → ISO, number preserved,
// types inferred from number_format.
func TestTableGet_ExecuteRoundTrip(t *testing.T) {
t.Parallel()
region := toolOutputStub(testToken, "read", `{"current_region":"A1:C2"}`)
cells := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[`+
`[{"value":"门店"},{"value":"月份"},{"value":"销售额"}],`+
`[{"value":"北京"},{"value":45306,"cell_styles":{"number_format":"yyyy-mm"}},{"value":259874,"cell_styles":{"number_format":"#,##0"}}]`+
`]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL, "--sheet-name", "销售"}, region, cells)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 1 {
t.Fatalf("want 1 sheet, got %d", len(sheets))
}
s0, _ := sheets[0].(map[string]interface{})
if s0["name"] != "销售" {
t.Errorf("name = %v, want 销售", s0["name"])
}
cols, _ := s0["columns"].([]interface{})
if len(cols) != 3 {
t.Fatalf("want 3 columns, got %d", len(cols))
}
c1, _ := cols[1].(map[string]interface{})
if c1["name"] != "月份" || c1["type"] != "date" || c1["format"] != "yyyy-mm" {
t.Errorf("col 月份 = %#v, want name=月份 date yyyy-mm", c1)
}
c2, _ := cols[2].(map[string]interface{})
if c2["type"] != "number" || c2["format"] != "#,##0" {
t.Errorf("col 销售额 = %#v, want number #,##0", c2)
}
rows, _ := s0["rows"].([]interface{})
r0, _ := rows[0].([]interface{})
if r0[1] != "2024-01-15" {
t.Errorf("date roundtrip = %v, want 2024-01-15 (serial 45306 → ISO)", r0[1])
}
if r0[2] != float64(259874) {
t.Errorf("number = %v, want 259874", r0[2])
}
}
func TestTableGet_DryRunIncludesCellRead(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, TableGet, []string{"--url", testURL, "--sheet-name", "S"})
found := false
for _, c := range calls {
body, _ := c.(map[string]interface{})["body"].(map[string]interface{})
if body == nil {
continue
}
if tn, _ := body["tool_name"].(string); tn == "get_cell_ranges" {
found = true
}
}
if !found {
t.Error("dry-run should include a get_cell_ranges read")
}
}
// TestTableGet_AllSheets covers the "read every sheet" path (no --sheet-name):
// get_workbook_structure lists sheets, then each is read in order.
func TestTableGet_AllSheets(t *testing.T) {
t.Parallel()
structure := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"s1","sheet_name":"A","index":0},{"sheet_id":"s2","sheet_name":"B","index":1}]}`)
regionA := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsA := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"x"}]]}]}`)
regionB := toolOutputStub(testToken, "read", `{"current_region":"A1:A2"}`)
cellsB := toolOutputStub(testToken, "read", `{"ranges":[{"cells":[[{"value":"项"}],[{"value":"y"}]]}]}`)
out, err := runShortcutWithStubs(t, TableGet,
[]string{"--url", testURL}, structure, regionA, cellsA, regionB, cellsB)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
sheets, _ := data["sheets"].([]interface{})
if len(sheets) != 2 {
t.Fatalf("want 2 sheets (all), got %d", len(sheets))
}
got := []string{
sheets[0].(map[string]interface{})["name"].(string),
sheets[1].(map[string]interface{})["name"].(string),
}
if got[0] != "A" || got[1] != "B" {
t.Errorf("sheet names = %v, want [A B] in workbook order", got)
}
}

View File

@@ -6,19 +6,14 @@ package sheets
import (
"context"
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/drive"
)
// ─── lark_sheet_workbook ──────────────────────────────────────────────
@@ -565,7 +560,7 @@ var SheetHideGridline = newSheetVisibilityShortcut(
var WorkbookCreate = common.Shortcut{
Service: "sheets",
Command: "+workbook-create",
Description: "Create a new spreadsheet (optionally pre-filled with --headers and --values).",
Description: "Create a new spreadsheet, optionally pre-filled with untyped --headers/--values or typed --sheets (type-faithful one-step create + write).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:create", "sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
@@ -575,6 +570,20 @@ var WorkbookCreate = common.Shortcut{
if strings.TrimSpace(runtime.Str("title")) == "" {
return common.FlagErrorf("--title is required")
}
// --sheets (typed) is an alternative, mutually exclusive data entry to the
// untyped --headers/--values. Gated on Changed (not just non-empty): an
// explicitly-given but empty --sheets (e.g. empty stdin / file) is an
// error, not a silent fall-through to creating an empty workbook.
if runtime.Changed("sheets") {
if strings.TrimSpace(runtime.Str("sheets")) == "" {
return common.FlagErrorf("--sheets was given but resolved to empty (empty stdin/file?); pass a typed payload, or drop --sheets to create an empty workbook")
}
if runtime.Str("headers") != "" || runtime.Str("values") != "" {
return common.FlagErrorf("--sheets is mutually exclusive with --headers/--values")
}
_, err := parseTablePutPayload(runtime)
return err
}
if runtime.Str("headers") != "" {
v, err := parseJSONFlag(runtime, "headers")
if err != nil {
@@ -610,6 +619,29 @@ var WorkbookCreate = common.Shortcut{
POST("/open-apis/sheets/v3/spreadsheets").
Desc("create spreadsheet").
Body(body)
// Typed --sheets path: preview the create POST, then one set_cell_range
// write per sheet (the first adopts the new workbook's default sheet).
// Mirrors +table-put's dry-run, against a placeholder token.
if runtime.Changed("sheets") {
if payload, err := parseTablePutPayload(runtime); err == nil {
headerStyle := runtime.Bool("header-style")
for i := range payload.Sheets {
s := &payload.Sheets[i]
matrix, _ := buildSheetMatrix(s, headerStyle, headerOn(s))
input := map[string]interface{}{
"excel_id": "<new-token>",
"sheet_name": s.Name,
"range": tablePutFullRange(s, len(matrix)),
"cells": matrix,
}
wireBody, _ := buildToolBody("set_cell_range", input)
dry.POST("/open-apis/sheet_ai/v2/spreadsheets/<new-token>/tools/invoke_write").
Desc(fmt.Sprintf("write typed sheet %q (%d data rows × %d cols) via set_cell_range", s.Name, len(s.Rows), len(s.Columns))).
Body(wireBody)
}
}
return dry
}
if fill, _ := buildInitialFillInput(runtime); fill != nil {
fill["excel_id"] = "<new-token>"
fill["sheet_id"] = "<first-sheet-id>" // resolved from the workbook at execute time
@@ -640,6 +672,30 @@ var WorkbookCreate = common.Shortcut{
result := map[string]interface{}{"spreadsheet": ss}
// Typed --sheets path: write type-faithful data into the brand-new
// workbook, adopting its default sheet as the first payload sheet so no
// empty "Sheet1" is left behind. Mutually exclusive with --headers/--values
// (enforced in Validate).
if runtime.Changed("sheets") {
payload, err := parseTablePutPayload(runtime)
if err != nil {
return err // already validated; defensive
}
firstSheetID, err := lookupFirstSheetID(ctx, runtime, token)
if err != nil {
return workbookCreatedButFillFailed(token, ss,
fmt.Sprintf("resolving its default sheet for the typed write failed: %v", err))
}
written, err := writeTypedSheets(ctx, runtime, token, payload, runtime.Bool("header-style"), firstSheetID)
if err != nil {
return workbookCreatedButFillFailed(token, ss,
fmt.Sprintf("typed write failed: %v", err))
}
result["sheets"] = written
runtime.Out(result, nil)
return nil
}
// --headers / --values are optional. buildInitialFillInput returns
// (nil, nil) when both are absent or empty, in which case we skip the
// fill entirely rather than dereferencing a nil map.
@@ -669,6 +725,7 @@ var WorkbookCreate = common.Shortcut{
},
Tips: []string{
"--headers and --values are optional follow-up writes. They use the same set_cell_range tool as +cells-set; partial failure leaves the spreadsheet created but empty.",
"--sheets writes typed, type-faithful data (dates → real dates, numbers keep precision) in one step — the create + typed write that +table-put can't do on its own. Mutually exclusive with --headers/--values; the new workbook's default sheet becomes the first typed sheet (no empty Sheet1 left behind).",
},
}
@@ -782,178 +839,62 @@ var WorkbookExport = common.Shortcut{
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
ext := runtime.Str("file-extension")
if ext == "" {
ext = "xlsx"
}
body := map[string]interface{}{
"token": token,
"type": "sheet",
"file_extension": ext,
}
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
dry := common.NewDryRunAPI().
POST("/open-apis/drive/v1/export_tasks").
Desc("create export task").
Body(body).
GET("/open-apis/drive/v1/export_tasks/<ticket>").
Desc("poll task status").
Params(map[string]interface{}{"token": token})
if strings.TrimSpace(runtime.Str("output-path")) != "" {
dry.GET("/open-apis/drive/v1/export_tasks/file/<file_token>/download").
Desc("download exported file")
}
return dry
p, _ := workbookExportParams(runtime)
p.OutputDir = strings.TrimSpace(runtime.Str("output-path"))
return drive.PlanExportDryRun(runtime, p)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
p, err := workbookExportParams(runtime)
if err != nil {
return err
}
ext := runtime.Str("file-extension")
if ext == "" {
ext = "xlsx"
}
body := map[string]interface{}{
"token": token,
"type": "sheet",
"file_extension": ext,
}
if sid := strings.TrimSpace(runtime.Str("sheet-id")); sid != "" {
body["sub_id"] = sid
}
taskData, err := runtime.CallAPI("POST", "/open-apis/drive/v1/export_tasks", nil, body)
if err != nil {
return err
}
ticket := common.GetString(taskData, "ticket")
if ticket == "" {
return output.Errorf(output.ExitAPI, "api_error", "export task created but ticket missing")
}
result := map[string]interface{}{
"ticket": ticket,
"file_extension": ext,
}
// Poll up to ~30s for completion.
var fileToken, fileName string
for attempt := 0; attempt < 15; attempt++ {
status, err := pollExportTask(runtime, token, ticket)
if err != nil {
return err
}
switch status.JobStatus {
case 0: // success
fileToken = status.FileToken
fileName = status.FileName
result["file_token"] = fileToken
result["file_name"] = fileName
result["file_size"] = status.FileSize
attempt = 999 // break outer loop
case 1, 2: // pending / in progress
time.Sleep(2 * time.Second)
continue
default: // any non-zero status outside the in-progress window is a failure
if status.JobErrorMsg != "" {
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed: %s", ticket, status.JobErrorMsg)
}
return output.Errorf(output.ExitAPI, "api_error", "export task %s failed with job_status=%d", ticket, status.JobStatus)
}
}
if fileToken == "" {
result["status"] = "polling_timeout"
runtime.Out(result, nil)
return nil
}
outPath := strings.TrimSpace(runtime.Str("output-path"))
if outPath == "" {
runtime.Out(result, nil)
return nil
}
saved, err := downloadExportFile(ctx, runtime, fileToken, outPath, fileName)
if err != nil {
return err
}
result["saved_path"] = saved
runtime.Out(result, nil)
return nil
applyWorkbookOutputPath(&p, runtime.FileIO(), runtime.Str("output-path"))
return drive.RunExport(ctx, runtime, p)
},
Tips: []string{
"Polls up to ~30s (15 × 2s). For very large workbooks rerun and pass --output-path to capture the file once status flips to success.",
"Polls for a bounded window; if the export is still running it returns a resume reference instead of blocking. Pass --output-path to download the file once ready (omit it to only create the export task and get the file token back).",
},
}
type exportTaskStatus struct {
JobStatus int
JobErrorMsg string
FileToken string
FileName string
FileSize int64
FileExtension string
}
func pollExportTask(runtime *common.RuntimeContext, token, ticket string) (exportTaskStatus, error) {
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/drive/v1/export_tasks/%s", validate.EncodePathSegment(ticket)),
map[string]interface{}{"token": token},
nil,
)
// workbookExportParams builds the shared drive export request for
// +workbook-export: spreadsheet token + sheet locator, pinned to type=sheet.
// workbook-export has always overwritten the target, so Overwrite is set. The
// --output-path → OutputDir/FileName split (which needs a Stat) is applied
// separately by applyWorkbookOutputPath so Validate/DryRun stay I/O-free.
func workbookExportParams(runtime *common.RuntimeContext) (drive.ExportParams, error) {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return exportTaskStatus{}, err
return drive.ExportParams{}, err
}
result := common.GetMap(data, "result")
if result == nil {
return exportTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "export task %s: empty result", ticket)
ext := runtime.Str("file-extension")
if ext == "" {
ext = "xlsx"
}
js, _ := util.ToFloat64(result["job_status"])
fs, _ := util.ToFloat64(result["file_size"])
return exportTaskStatus{
JobStatus: int(js),
JobErrorMsg: common.GetString(result, "job_error_msg"),
FileToken: common.GetString(result, "file_token"),
FileName: common.GetString(result, "file_name"),
FileSize: int64(fs),
FileExtension: common.GetString(result, "file_extension"),
return drive.ExportParams{
Token: token,
DocType: "sheet",
FileExtension: ext,
SubID: strings.TrimSpace(runtime.Str("sheet-id")),
Overwrite: true,
}, nil
}
func downloadExportFile(ctx context.Context, runtime *common.RuntimeContext, fileToken, outPath, preferredName string) (string, error) {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/export_tasks/file/%s/download", validate.EncodePathSegment(fileToken)),
}, larkcore.WithFileDownload())
if err != nil {
return "", output.ErrNetwork("download failed: %s", err)
// applyWorkbookOutputPath maps the single --output-path flag onto the drive
// export OutputDir/FileName pair, preserving the legacy behavior: empty = no
// download (return the ready file token only); an existing directory = download
// into it under the server-provided name; otherwise treat it as a file path and
// split into dir + base name.
func applyWorkbookOutputPath(p *drive.ExportParams, fio fileio.FileIO, outputPath string) {
outputPath = strings.TrimSpace(outputPath)
if outputPath == "" {
return
}
if apiResp.StatusCode >= 400 {
return "", output.ErrNetwork("download failed: HTTP %d: %s", apiResp.StatusCode, string(apiResp.RawBody))
if info, err := fio.Stat(outputPath); err == nil && info.IsDir() {
p.OutputDir = outputPath
return
}
target := outPath
if info, statErr := runtime.FileIO().Stat(outPath); statErr == nil && info.IsDir() {
name := strings.TrimSpace(preferredName)
if name == "" {
name = client.ResolveFilename(apiResp)
}
target = filepath.Join(outPath, name)
}
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: apiResp.Header.Get("Content-Type"),
ContentLength: int64(len(apiResp.RawBody)),
}, strings.NewReader(string(apiResp.RawBody))); err != nil {
return "", common.WrapSaveErrorByCategory(err, "io")
}
resolved, _ := runtime.FileIO().ResolvePath(target)
if resolved == "" {
resolved = target
}
return resolved, nil
p.OutputDir = filepath.Dir(outputPath)
p.FileName = filepath.Base(outputPath)
}
// lookupSheetIndex finds a sub-sheet by id or name and returns its canonical
@@ -1045,3 +986,45 @@ func lookupFirstSheetID(ctx context.Context, runtime *common.RuntimeContext, tok
}
return bestID, nil
}
// ─── +workbook-import (reuses drive import core, cli_status: cli-only) ──
//
// Imports a local xlsx/xls/csv file as a brand-new spreadsheet. The full
// upload → create-task → poll flow is the shared drive import core
// (drive.RunImport); this shortcut only pins the target type to "sheet" and
// omits the bitable-only --target-token. Symmetric with +workbook-export.
// Not exposed as an MCP tool.
// WorkbookImport imports a local spreadsheet file as a new Feishu spreadsheet
// by delegating to the shared drive import core with type fixed to "sheet".
var WorkbookImport = common.Shortcut{
Service: "sheets",
Command: "+workbook-import",
Description: "Import a local xlsx/xls/csv file as a new spreadsheet (async + poll). Reuses the drive import core with type fixed to sheet.",
Risk: "write",
Scopes: []string{"docs:document.media:upload", "docs:document:import"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+workbook-import"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return drive.ValidateImport(workbookImportParams(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return drive.PlanImportDryRun(runtime, workbookImportParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return drive.RunImport(ctx, runtime, workbookImportParams(runtime))
},
}
// workbookImportParams builds the drive import request for +workbook-import,
// pinning DocType to "sheet". The bitable-only --target-token is intentionally
// not exposed here — use drive +import for non-sheet import targets.
func workbookImportParams(runtime *common.RuntimeContext) drive.ImportParams {
return drive.ImportParams{
File: runtime.Str("file"),
DocType: "sheet",
FolderToken: runtime.Str("folder-token"),
Name: runtime.Str("name"),
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// TestWorkbookExport_ExecuteExportOnly covers the no-download path: without
// --output-path, +workbook-export delegates to the shared drive export core
// with OutputDir="" so it creates + polls the export task and returns the ready
// file token without writing a local file (downloaded=false).
func TestWorkbookExport_ExecuteExportOnly(t *testing.T) {
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/export_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_export"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/export_tasks/tk_export",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"job_status": float64(0),
"file_token": "ftk_xlsx",
"file_name": "report.xlsx",
"file_size": float64(2048),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "xlsx", "--as", "user",
}, stubs...)
if err != nil {
t.Fatalf("export-only execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if env.Data["ready"] != true {
t.Errorf("ready = %v, want true", env.Data["ready"])
}
if env.Data["downloaded"] != false {
t.Errorf("downloaded = %v, want false (no --output-path)", env.Data["downloaded"])
}
if env.Data["file_token"] != "ftk_xlsx" {
t.Errorf("file_token = %v, want ftk_xlsx", env.Data["file_token"])
}
if env.Data["doc_type"] != "sheet" {
t.Errorf("doc_type = %v, want sheet", env.Data["doc_type"])
}
}

View File

@@ -0,0 +1,135 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// chdirTemp switches into a fresh temp dir for the duration of the test and
// restores the original cwd afterwards. +workbook-import is the first sheets
// shortcut that stat()s a real local file, so these tests need a working dir.
func chdirTemp(t *testing.T) {
t.Helper()
orig, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
if err := os.Chdir(t.TempDir()); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(orig) })
}
// TestWorkbookImport_DryRunPinsSheetType verifies the shortcut delegates to the
// shared drive import core and hard-codes the import target type to "sheet".
func TestWorkbookImport_DryRunPinsSheetType(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.xlsx", []byte("fake-xlsx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
calls := parseDryRunAPI(t, WorkbookImport, []string{"--file", "./data.xlsx"})
var createBody map[string]interface{}
for _, c := range calls {
cm, _ := c.(map[string]interface{})
if u, _ := cm["url"].(string); u == "/open-apis/drive/v1/import_tasks" {
createBody, _ = cm["body"].(map[string]interface{})
}
}
if createBody == nil {
t.Fatalf("no import_tasks create call in dry-run: %#v", calls)
}
if createBody["type"] != "sheet" {
t.Errorf("import type = %v, want sheet (must be pinned regardless of file)", createBody["type"])
}
if createBody["file_extension"] != "xlsx" {
t.Errorf("file_extension = %v, want xlsx", createBody["file_extension"])
}
}
// TestWorkbookImport_RejectsNonSheetFile ensures a file that cannot become a
// spreadsheet (e.g. .docx) is rejected up front by the pinned-sheet validation.
func TestWorkbookImport_RejectsNonSheetFile(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("notes.docx", []byte("fake-docx"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// Validate runs before DryRun, so the pinned-sheet check rejects .docx up
// front and the error surfaces through the normal envelope/err path.
stdout, stderr, err := runShortcutCapturingErr(t, WorkbookImport, []string{"--file", "./notes.docx", "--dry-run"})
if err == nil || !strings.Contains(stdout+stderr+err.Error(), "can only be imported") {
t.Errorf("expected .docx → sheet type-mismatch rejection; got stdout=%s stderr=%s err=%v", stdout, stderr, err)
}
}
// TestWorkbookImport_ExecuteCreatesSheet runs the full upload → create → poll
// flow against stubs and asserts the resulting URL is a /sheets/ link.
func TestWorkbookImport_ExecuteCreatesSheet(t *testing.T) {
chdirTemp(t)
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
stubs := []*httpmock.Stub{
{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"file_token": "file_import_media"},
},
},
{
Method: "POST",
URL: "/open-apis/drive/v1/import_tasks",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"ticket": "tk_sheet"},
},
},
{
Method: "GET",
URL: "/open-apis/drive/v1/import_tasks/tk_sheet",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": map[string]interface{}{
"token": "shtcn_imported",
"type": "sheet",
"job_status": float64(0),
}},
},
},
}
out, err := runShortcutWithStubs(t, WorkbookImport, []string{"--file", "./data.csv", "--as", "user"}, stubs...)
if err != nil {
t.Fatalf("import execute failed: %v\n%s", err, out)
}
idx := strings.Index(out, "{")
if idx < 0 {
t.Fatalf("execute output has no JSON envelope:\n%s", out)
}
var env struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal([]byte(out[idx:]), &env); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, out)
}
if url, _ := env.Data["url"].(string); !strings.Contains(url, "/sheets/") {
t.Errorf("imported url = %q, want a /sheets/ link", url)
}
if tok, _ := env.Data["token"].(string); tok != "shtcn_imported" {
t.Errorf("token = %q, want shtcn_imported", tok)
}
}

View File

@@ -361,21 +361,21 @@ func TestWorkbookCreate_DataValidation(t *testing.T) {
}
}
// TestWorkbookExport_DryRun checks the 2-or-3 step plan depending on
// --output-path. The order should be: POST → GET (poll) → optional GET
// (download).
// TestWorkbookExport_DryRun verifies the export dry-run now delegates to the
// shared drive export core: a single create-task POST (poll + download are
// described inline rather than as separate api entries).
func TestWorkbookExport_DryRun(t *testing.T) {
t.Parallel()
t.Run("xlsx without --output-path → 2 steps", func(t *testing.T) {
t.Run("xlsx create-task body pins type=sheet", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{"--url", testURL, "--file-extension", "xlsx"})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + poll)", len(calls))
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1 (create export task)", len(calls))
}
create := calls[0].(map[string]interface{})
if create["url"] != "/open-apis/drive/v1/export_tasks" {
t.Errorf("first url = %v", create["url"])
t.Errorf("url = %v", create["url"])
}
body, _ := create["body"].(map[string]interface{})
if body["type"] != "sheet" || body["file_extension"] != "xlsx" || body["token"] != testToken {
@@ -383,22 +383,18 @@ func TestWorkbookExport_DryRun(t *testing.T) {
}
})
t.Run("csv → 3 steps, with sub_id", func(t *testing.T) {
t.Run("csv includes sub_id from --sheet-id", func(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookExport, []string{
"--url", testURL, "--file-extension", "csv", "--sheet-id", "sh1",
"--output-path", "/tmp/out.csv",
})
if len(calls) != 3 {
t.Fatalf("api calls = %d, want 3", len(calls))
if len(calls) != 1 {
t.Fatalf("api calls = %d, want 1", len(calls))
}
body, _ := calls[0].(map[string]interface{})["body"].(map[string]interface{})
if body["sub_id"] != "sh1" {
t.Errorf("csv export missing sub_id: %#v", body)
}
dl := calls[2].(map[string]interface{})
if !strings.Contains(dl["url"].(string), "/export_tasks/file/") {
t.Errorf("download url = %v", dl["url"])
if body["type"] != "sheet" || body["sub_id"] != "sh1" {
t.Errorf("csv export body = %#v (want type=sheet, sub_id=sh1)", body)
}
})

View File

@@ -197,12 +197,12 @@ func cellsSetStyleInput(runtime flagView, token, sheetID, sheetName string) (map
return input, nil
}
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet, only writing
// plain values. Use +cells-set for anything richer (formula / style / note).
// CsvPut wraps set_range_from_csv: dump a CSV blob into a sheet. A cell whose
// text starts with = is evaluated as a formula; use +cells-set for styles / notes / images.
var CsvPut = common.Shortcut{
Service: "sheets",
Command: "+csv-put",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (plain values only, auto-expands sheet if needed).",
Description: "Paste RFC-4180 CSV into a sheet at --start-cell (values or formulas: a leading = is evaluated as a formula; no styles / comments; auto-expands sheet if needed).",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},

View File

@@ -42,6 +42,7 @@ func shortcutList() []common.Shortcut {
SheetHideGridline,
WorkbookCreate,
WorkbookExport,
WorkbookImport,
// lark_sheet_sheet_structure
SheetInfo,
@@ -58,6 +59,10 @@ func shortcutList() []common.Shortcut {
CellsGet,
CsvGet,
DropdownGet,
TableGet,
// lark_sheet_recover
Recover,
// lark_sheet_search_replace
CellsSearch,
@@ -69,6 +74,7 @@ func shortcutList() []common.Shortcut {
CellsSetImage,
CsvPut,
DropdownSet,
TablePut,
// lark_sheet_range_operations
CellsClear,

View File

@@ -295,12 +295,14 @@ lark-cli drive <resource> <method> [flags] # 调用 API
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
>
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`,必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) 的模板通过 `--params` 传 `folder_token` / `page_token`,并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
### files
- `copy` — 复制文件
- `create_folder` — 新建文件夹
- `list` — 获取文件夹下的清单
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
- `patch` — 修改文件标题
### file.comments

View File

@@ -0,0 +1,158 @@
# drive files list原生 API读取 Drive 文件夹清单)
`drive files list` 是原生 API 命令,不是 shortcut。它用于读取 Drive 根目录或某个 Drive 文件夹的直接子项如果要递归盘点目录树Agent 必须基于返回的子文件夹 token 继续调用本命令。
## 什么时候使用
| 场景 | 是否使用 | 说明 |
|------|----------|------|
| 盘点一个已确认的 Drive 文件夹树 | 使用 | 从目标 `folder_token` 开始递归列取 |
| 盘点用户明确确认的 Drive 根目录 | 使用 | 第一层用空 `folder_token`,子文件夹继续按普通文件夹递归 |
| 验证移动 / 创建后的实际位置 | 使用 | 读取目标目录直接子项,再按需递归验证 |
| 根据关键词、标题、时间、owner 找资源 | 不使用 | 优先用 `drive +search` |
| 读取 Docx 正文内容 | 不使用 | 用 `docs +fetch --api-version v2` |
| 读取 Sheet / Base 内部数据 | 不使用 | 切到 `lark-sheets` / `lark-base` |
## 标准命令模板
读取普通文件夹:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200}' \
--format json
```
继续翻页:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
--format json
```
读取当前用户 Drive 根目录的直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"","page_size":200}' \
--format json
```
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
## 参数规则
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
## 返回结构与解析
`--format json` 输出中Agent 只使用 `data` 中符合 `schema drive.files.list` 的 API 返回字段。
常用字段:
| 字段 | 用途 |
|------|------|
| `data.files` | 当前页直接子项列表 |
| `data.has_more` | 当前目录是否还有下一页 |
| `data.next_page_token` | 下一页 token`has_more=true` 时放回 `--params.page_token` |
| `data.files[].type` | 文件类型;等于 `folder` 时可递归 |
| `data.files[].token` | 当前资源 token文件夹递归时作为下一层 `folder_token` |
| `data.files[].name` | 生成路径和展示标题 |
| `data.files[].url` | 资源浏览器链接 |
| `data.files[].owner_id` | 资源所有者 |
| `data.files[].created_time` / `data.files[].modified_time` | 创建 / 更新时间 |
字段名以 `schema drive.files.list` 为准。Agent MUST 以实际返回为准;如果字段缺失,先用 `schema drive.files.list` 或一页样本确认结构,不要猜测。
## 根目录语义
1. `folder_token` 为空字符串或省略时,请求的是当前调用用户的 Drive 根目录直接子项。
2. 根目录返回值不是递归结果;不能把根目录第一页或直接子项数量当作整个云空间资源总量。
3. 根目录只作为目录树起点。返回的子文件夹必须用其自己的 `folder_token` 继续调用 `drive files list`
4. 根据 schema 描述,根目录第一层清单不支持分页且不返回快捷方式;不要基于根目录响应推断子文件夹内容、根目录第一层快捷方式或无法分页的根目录剩余项已经被覆盖。
## 递归盘点规则
1. 只对返回项中的 `folder` 类型继续递归。
2. 每个目录独立维护分页状态;一个目录的 `page_token` 不可复用于其他目录。
3. 对每个目录持续请求,直到返回 `has_more=false`。非根目录的普通文件夹清单可能返回 `type=shortcut` 条目;不要假设这些条目会携带 `shortcut_info` 目标信息。
4. 递归过程中生成稳定 `path`;不要只保存标题,否则同名资源无法区分。
5. URL、owner、创建时间和更新时间优先使用 `files.list` 返回字段;如果字段缺失或需要批量补齐,再使用 `drive metas batch_query`。不要从标题或路径猜元数据。
6. 深度、数量、每目录页数等限制只能作为内部批次 checkpoint不能作为递归完成条件。
7. 达到深度 checkpoint 时,把更深层子文件夹加入 continuation queue并在下一批从这些子文件夹继续保留原始 `path`
8. 达到数量 checkpoint 时,保存当前目录、当前页 token、剩余目录队列和已收集资源计数并立即继续下一批不要进入分析或规划阶段。
### 递归算法
Agent 盘点 Drive 文件夹树时,按以下顺序执行:
1. 初始化待处理队列,放入起点目录:
- 普通文件夹:`{folder_token:"<folder_token>", path:"<folder_name>"}`
- Drive 根目录:`{folder_token:"", path:""}`
2. 从队列取出一个目录,请求第一页。
3.`(folder_token, page_token)` 生成当前页 key同一页 key 只允许追加一次,避免 retry 时重复计数。
4.`data.files` 取当前页直接子项,按 `dedupe_key` 去重后生成 `path` 并加入结果集。
5. 如果新追加的子项是 `folder`,把子文件夹 token、子路径和 depth 加入队列。
6. 如果 `has_more=true`,取 `data.next_page_token` 继续请求同一目录下一页。
7. 同一目录分页结束后,再处理队列中的下一个目录。
8. 如果达到深度、数量或每目录页数 checkpoint把当前目录 / 页 token / 剩余队列 / 已访问页 key / dedupe key 写入 continuation queue并继续下一批。
9. 普通队列和 continuation queue 都为空,且没有分页 blocker 时,才可以认为本次确认范围盘点完成。
简化伪代码:
```text
queue = [root_or_start_folder]
visited_pages = set()
dedupe_keys = set()
while queue not empty:
folder = queue.pop()
page_token = folder.page_token or ""
retry_without_token = 0
while true:
page_key = (folder.folder_token, page_token or "first")
page = drive files list(folder.folder_token, page_token)
if page_key not in visited_pages:
append only files whose dedupe_key is not in dedupe_keys
enqueue newly appended child folders with folder_token, path, and depth
add page_key to visited_pages
if page.has_more != true:
break
next = page.next_page_token
if next is empty:
retry_without_token += 1
if retry_without_token >= 3:
record pagination blocker for folder
break
continue
page_token = next
retry_without_token = 0
```
## 分页与异常
1. 默认手动处理 `has_more` 和返回中的 `next_page_token`
2. 不要使用 `--page-all` 作为脚本 JSON 解析输入;自动翻页输出可能不适合直接 `json.loads`
3. 如果 `has_more=true` 但没有可用的 `next_page_token`,重试同一页最多 3 次。
4. 重试后仍无 continuation token 时,记录受影响的目录和 pagination blocker停止扩展该目录不要无限循环也不要宣称该目录已完整覆盖。
5. 如果触发深度、数量或每目录页数限制,把它视为批处理 checkpoint在确认范围内继续下一批而不是把当前结果说成完整。
6. 不要因为达到 `max_depth=3``max_items=500` 或类似单批阈值就结束盘点;只有队列耗尽或遇到权限 / API / 工具预算 blocker 才能结束当前确认范围的盘点。
## JSON 解析规则
1. stdout 是数据通道。脚本解析 JSON 时只读取 stdout。
2. stderr 可能包含刷新 token、进度、warning 或其他提示;不要把 stderr 合并进 JSON 输入,例如不要用 `2>&1` 后再 `json.loads`
3. 使用 `--format json` 保持 stdout 为结构化 JSON解析 Drive 文件清单时只读取 `data.files` / `data.has_more` / `data.next_page_token` 等 schema 字段。
4. 不要用根目录响应数量或当前页数量推断递归总量;递归总量必须由实际遍历并去重后的资源集合计算。
## 常见错误
| 错误用法 | 问题 | 正确做法 |
|----------|------|----------|
| `lark-cli drive files list --folder-token <token>` | `files.list` 不提供 `--folder-token` flag | 使用 `--params '{"folder_token":"<token>"}'` |
| 根目录返回 N 项就认为云空间只有 N 项 | 根目录只返回直接子项,不是递归结果 | 对返回的子文件夹继续递归 |
| `--page-all \| python json.loads(...)` | 自动翻页输出不适合作为单个 JSON 对象解析 | 手动使用 `page_token` 翻页并逐页解析 |
| `cmd 2>&1` 后解析 JSON | stderr 提示污染 JSON 输入 | 只解析 stdoutstderr 作为日志处理 |

View File

@@ -24,7 +24,8 @@ MUST:
4. Switch to `lark-sheets` / `lark-base` only when sheet / bitable title and path are insufficient.
5. Record read evidence for classification.
6. Continue reading low-confidence resources in internal batches until all supported low-confidence resources in the current inventory are processed or a blocker occurs.
7. Output progress / summary without asking the user to continue between batches.
7. Apply `Analysis Progress Reporting`.
8. Output progress / summary without asking the user to continue between batches.
Exit: low-confidence items are classified or marked `needs_review=true`.
@@ -93,6 +94,30 @@ Output this summary:
- After every 50 processed low-confidence resources.
- Once after low-confidence reading finishes.
- About every 60 seconds during long-running reads, even if fewer than 50 additional resources were processed.
### Analysis Progress Reporting
Applies to `CONTENT_READ`, `ISSUE_ANALYSIS`, and `RULE_GENERATION`.
Rules:
1. For `CONTENT_READ`, use `Low-Confidence Read Summary` as the progress report format.
2. For `ISSUE_ANALYSIS`, if analysis runs longer than about 60 seconds, output progress about every 60 seconds with current stage, processed resource count when known, detected problem type count when known, and the next analysis step.
3. For `RULE_GENERATION`, if classification rule or target-tree generation runs longer than about 60 seconds, output progress about every 60 seconds with current stage, classified item count when known, unresolved item count when known, and target category / path count when known.
4. Progress reports MUST be factual and stage-specific. Do not output generic "still running" messages without counts or the current stage.
5. Do not ask the user to continue between internal batches unless auth, permission, API, target scope, or environment blockers occur.
6. Do not expose internal chain-of-thought, raw tokens, or intermediate rule drafts.
Examples:
```text
分析进度:正在归纳整理问题,已处理 <processed_count>/<resource_count> 项资源,已识别 <problem_type_count> 类问题。继续生成整理思路,不会执行移动或创建。
```
```text
规则生成进度:正在生成分类规则和目标目录,已归类 <classified_count> 项,待人工确认 <needs_review_count> 项。继续生成完整计划前置数据。
```
## State: ISSUE_ANALYSIS
@@ -103,8 +128,9 @@ MUST:
1. Detect problems from organization perspective only. Do not generate research conclusions.
2. Generate an organization approach based on inventory, low-confidence read evidence, and detected problems.
3. Include how non-reused source containers will be handled after their contents are moved.
4. Output `Inventory And Organization Approach Decision`.
5. Stop and wait for the user to confirm the approach before `RULE_GENERATION`.
4. Apply `Analysis Progress Reporting`.
5. Output `Inventory And Organization Approach Decision`.
6. Stop and wait for the user to confirm the approach before `RULE_GENERATION`.
Problem rules:
@@ -161,10 +187,10 @@ MUST output evidence count or example paths. Do not output only abstract judgmen
是否基于这个整理思路生成目标目录和移动 / 创建计划?
你可以选择:
A. 基于这个思路生成目标目录和计划
B. 调整整理思路
C. 查看问题详情
D. 取消本次整理
1. 基于这个思路生成目标目录和计划
2. 调整整理思路
3. 查看问题详情
4. 取消本次整理
```
## State: RULE_GENERATION
@@ -181,7 +207,8 @@ MUST:
6. For non-reused source containers, ensure `target_tree` includes a source-container cleanup target, defaulting to `待人工确认/待清理旧目录`, unless the user explicitly asks to keep source containers in place.
7. Ensure target tree can contain every planned `target_path`.
8. Ensure the target tree contains a manual confirmation target named `待人工确认` unless the user explicitly provides an equivalent name.
9. Continue to `PLAN_GENERATION` without a separate target-tree-only confirmation.
9. Apply `Analysis Progress Reporting`.
10. Continue to `PLAN_GENERATION` without a separate target-tree-only confirmation.
### Classification

View File

@@ -10,8 +10,9 @@ Before executing rules in this file:
1. Follow [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) for identity, auth, and permission handling.
2. For Wiki / personal library targets, follow [`../../lark-wiki/SKILL.md`](../../lark-wiki/SKILL.md).
3. For Drive search targets, follow [`lark-drive-search.md`](lark-drive-search.md).
4. For URL / token inspection, follow [`lark-drive-inspect.md`](lark-drive-inspect.md) and [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md).
3. For Drive folder inventory, follow [`lark-drive-files-list.md`](lark-drive-files-list.md).
4. For Drive search targets, follow [`lark-drive-search.md`](lark-drive-search.md).
5. For URL / token inspection, follow [`lark-drive-inspect.md`](lark-drive-inspect.md) and [`../../lark-wiki/references/lark-wiki-node-get.md`](../../lark-wiki/references/lark-wiki-node-get.md).
## State: PARSE_SCOPE
@@ -87,6 +88,10 @@ Clarification template:
请确认是否按这个范围继续?
```
Scope confirmation is user-facing. It MUST confirm only the business scope, environment / profile, identity, and whether write operations will run.
Do not display internal batching controls in scope confirmation, including `max_depth`, `max_items`, `page_size`, page tokens, retry counts, or `partial=true`. For example, when the user confirms Drive root, say the scope is the Drive root tree; do not append "recursive depth at most 3" or "at most 500 resources".
## State: INVENTORY
Entry: `target_scope` confirmed.
@@ -96,20 +101,57 @@ MUST:
1. Recursively list resources according to target type.
2. Generate `path` during traversal.
3. Normalize all results to `ResourceItem`.
4. Track pagination, depth, and item limits.
5. Set `partial=true` when limits are hit.
6. Output `Inventory Summary`.
7. Continue to `CONTENT_READ` without asking the user unless auth, permission, API, target scope, or environment blockers occur.
4. Track pagination, depth, item limits, and continuation checkpoints.
5. Treat pagination, depth, item, and per-folder page limits as batching checkpoints; continue inventory in the confirmed scope unless blocked.
6. Set `partial=true` only when inventory cannot continue because of auth, permission, API / pagination failure after retries, API coverage limitations, tool budget, target scope, or environment blockers.
7. Apply `Inventory Progress Reporting`.
8. Output `Inventory Summary`.
9. Do not leave `INVENTORY` while `inventory_continuation_state` has queued folders, nodes, pages, or slices that can still be fetched.
10. Continue to `CONTENT_READ` without asking the user only after the confirmed scope is exhausted or blocked.
### Inventory Limits
### Inventory Batch Checkpoints
| Scope | Default Limit | If Limit Is Hit |
|-------|---------------|-----------------|
| Wiki recursion | `max_depth=3`, `max_items=500`; follow `lark-wiki-node-list` pagination | Set `partial=true`; list covered paths and suggested next first-level directories |
| Drive folder recursion | `max_depth=3`, `max_items=500`, max 10 pages per folder, `page_size=50` | Set `partial=true`; list folders not drilled into |
| Search discovery | `page_size=20`, `max_items=500`; continue pages until `has_more=false` or `max_items` is reached | Set `partial=true`; report collected_count, service_total when available, page_count, and continuation information |
| Scope | Internal Batch Checkpoint | Required Continuation |
|-------|---------------------------|-----------------------|
| Wiki recursion | `max_depth=3`, `max_items=500`; follow `lark-wiki-node-list` pagination | Record queued nodes / paths in `inventory_continuation_state` and immediately continue the next internal batch within the confirmed scope unless blocked |
| Drive folder tree | `max_depth=3`, `max_items=500`, max 10 pages per folder, `page_size=200` | Record queued folders / pages in `inventory_continuation_state` and immediately continue the next internal batch within the confirmed scope unless blocked |
| Search discovery | `page_size=20`, `max_items=500`; continue pages until `has_more=false` | Record remaining pages / slices in `inventory_continuation_state` and immediately continue the next internal batch within the confirmed scope unless blocked |
If the user explicitly asks for full processing, batch by first-level directory, Wiki space, or time window. Do not remove all limits in one run.
These checkpoints are pacing controls, not coverage limits. If the confirmed scope still has queued work after a checkpoint, continue with the next internal batch instead of presenting the current `resource_items` as final inventory or moving to content analysis.
When a depth checkpoint is reached, enqueue the child folders / nodes that would exceed the current batch depth; the next batch starts from those queued children with their original paths preserved. When an item checkpoint is reached, persist the current folder / node / page cursor plus the remaining queue, visited page keys, and resource dedupe keys, then continue from that checkpoint before analysis or planning.
If tool budget would be exceeded for a very large confirmed scope, stop only at that blocker, report that the inventory is incomplete, and suggest batching by first-level directory, Wiki space, or time window. Do not stop merely because a depth or item checkpoint was reached.
### Inventory Continuation Rules
1. Pagination, depth, item, and per-folder page limits are internal batching checkpoints.
2. When a checkpoint is reached, record `inventory_continuation_state` with `scope`, `queue`, `current_cursor`, `visited_page_keys`, `dedupe_keys`, and `blockers`; Drive queue entries MUST contain `folder_token`, `path`, `depth`, and `page_token`; Wiki queue entries MUST contain `space_id` / `node_token`, `path`, `depth`, and pagination cursor; search entries MUST contain query / filters and pagination cursor.
3. A depth checkpoint MUST enqueue deeper folders / nodes; it MUST NOT discard them or treat the current depth as final coverage.
4. An item-count checkpoint MUST persist the current cursor and queue; it MUST NOT transition to `CONTENT_READ`, `ISSUE_ANALYSIS`, or `PLAN_GENERATION` while fetchable work remains.
5. If `inventory_continuation_state` is missing, corrupt, or lacks required fields for the current scope, set `partial=true`, record the checkpoint blocker, and do not claim full coverage.
6. Do not set `partial=true` solely because a valid batching checkpoint was reached.
7. Set `partial=true` only when continuation is blocked by auth, permission, API / pagination failure after retries, API coverage limitations, tool budget, target scope, or environment blockers.
8. Do not claim full coverage until the continuation queue for the confirmed scope is exhausted or blocked.
### Inventory Progress Reporting
Inventory can be long-running when a Drive root, large folder tree, Wiki space, or broad search scope is confirmed.
Rules:
1. When inventory starts, output one concise stage notice with the confirmed scope type and the fact that no write operation will be executed.
2. If inventory runs longer than about 60 seconds, output progress about every 60 seconds.
3. Progress reports SHOULD include only fields that are currently known: scanned folders / nodes, collected resources, current depth, queued folders / nodes, current search page / slice, and current blocker if any.
4. When a batching checkpoint is reached and continuation will proceed automatically, report it as continuing inventory, not as a user action request.
5. Do not output filler such as "still running" without current counts or current stage.
6. Do not expose raw folder tokens, page tokens, retry logs, or `partial=true` unless the user explicitly asks to view inventory coverage details.
Example:
```text
盘点进度:已扫描 <scanned_container_count> 个目录 / 节点,收集 <resource_count> 项资源,队列剩余 <queued_container_count> 个目录 / 节点。继续盘点,不会执行移动或创建。
```
### Wiki Inventory Rules
@@ -120,11 +162,13 @@ If the user explicitly asks for full processing, batch by first-level directory,
### Drive Inventory Rules
1. Use CLI command family `drive files list` according to `lark-drive` API rules; its schema path is `drive.files.list`.
2. Recurse only into `folder` items.
3. Use `drive metas batch_query` when URL, owner, created time, or updated time is needed.
4. Continue pages by feeding `next_page_token` into request param `page_token`.
5. Prefer explicit `folder_token`; querying root with empty `folder_token` may return broad root data and may not paginate as expected.
1. Use `drive files list` according to [`lark-drive-files-list.md`](lark-drive-files-list.md); its schema path is `drive.files.list`.
2. Use the same Drive folder-tree traversal for Drive root and ordinary folders after the first request. Drive root differs only for the first-level request: it uses omitted or empty `folder_token`, does not support pagination, and does not return root-level shortcuts according to schema; returned child folders MUST still be listed by their own folder tokens like ordinary folders, and those ordinary folder lists may return `type=shortcut` entries. For a Drive root target, record this root-level shortcut coverage caveat, set `partial=true` only if the user requested full root-level shortcut coverage or root pagination cannot continue, and do not claim root-level shortcut coverage as complete.
3. Recurse only into `folder` items within the confirmed scope.
4. For each directory, continue pages manually by feeding the returned `next_page_token` into request param `page_token`. Do not rely on `--page-all` for inventory.
5. If a page returns `has_more=true` but no usable `next_page_token`, retry the same page request up to 3 times. If retries still cannot produce a continuation token, set `partial=true` for that directory and record the pagination blocker.
6. Use `drive metas batch_query` when URL, owner, created time, or updated time is needed.
7. Pagination blocker details such as `partial=true`, folder token, page token, and retry logs are internal by default. Do not show them to the user unless the user explicitly asks to view inventory coverage details.
### Search Inventory Rules
@@ -132,10 +176,11 @@ If the user explicitly asks for full processing, batch by first-level directory,
2. If a search result is a Wiki item and lacks `node_token`, resolve it with `drive +inspect` or `wiki +node-get` before dedupe.
3. If Wiki identity still cannot be resolved, keep the item, set `needs_review=true`, and record `needs_review_reason`.
4. For search scope, use `page_size=20` unless a lower value is required by the command.
5. Continue fetching pages until `has_more=false` or `max_items` is reached.
6. Do not stop at an arbitrary sample size such as first 5 pages unless the user explicitly asks for sampling or auth, permission, API, environment, or tool-budget blockers occur.
7. If `service_total` / result total is greater than collected items, set `partial=true` and show collected_count, service_total, page_count, and continuation information.
8. Do not present a partial search sample as complete inventory. Before generating a full organization plan from partial search results, ask whether to continue fetching more pages or proceed with sample-based planning.
5. Continue fetching pages until `has_more=false`.
6. If `max_items=500` is reached in one batch, record the current search cursor in `inventory_continuation_state` and continue the next internal batch without asking the user.
7. Do not stop at an arbitrary sample size such as first 5 pages unless the user explicitly asks for sampling or auth, permission, API, environment, or tool-budget blockers occur.
8. If `service_total` / result total is greater than collected items, treat it as continuation evidence: continue fetching when a cursor / page is available; set `partial=true` only if continuation is blocked.
9. Do not present a partial search sample as complete inventory. Before generating a full organization plan from partial search results, continue fetching available pages unless the user explicitly asked for sampling or a blocker prevents continuation.
## ResourceItem
@@ -179,7 +224,9 @@ ResourceItem rules:
## Inventory Summary
```text
已完成盘点。
已完成当前可覆盖范围盘点。
<仅当适用覆盖说明Drive 根目录第一层清单不返回快捷方式;本次盘点不包含根目录第一层快捷方式。根目录下子文件夹会按普通文件夹继续盘点,普通文件夹内返回的 `type=shortcut` 条目仍会被纳入资源清单。>
| 指标 | 数量 |
|------|------|
@@ -202,4 +249,5 @@ ResourceItem rules:
| Environment / profile is ambiguous | Ask user to confirm prod / BOE / PRE and profile | Do not cross environment boundaries |
| Missing API scope | Follow `lark-shared` permission handling and stop | Do not retry the same command repeatedly |
| Resource access denied | Stop and follow the main workflow `Permission Request Gate` | Do not request permission automatically or in batch |
| Pagination / depth / item limit reached | Set `partial=true`; record uncovered range and continuation command | Do not claim full coverage |
| Pagination / depth / item checkpoint reached | Record `inventory_continuation_state` and continue inventory in the confirmed scope | Do not set `partial=true` solely because a batching checkpoint was reached |
| Pagination cursor missing after retries / API pagination failure | Set `partial=true`; record the affected directory and blocker | Do not loop indefinitely or claim full coverage |

View File

@@ -24,7 +24,8 @@ MUST:
4. Apply `Plan Pagination`.
5. Set `active_plan_items` to the latest complete plan.
6. Keep complete plan internally even if only one page is displayed.
7. Output `Target Tree And Plan Overview` or requested plan page, then wait.
7. Apply `Plan Generation Progress Reporting`.
8. Output `Target Tree And Plan Overview` or requested plan page, then wait.
### Plan Generation
@@ -44,6 +45,25 @@ MUST:
| Target parent token unresolved | Keep plan item but block execution until token is resolved |
| Resource title is poor or inconsistent | Report the naming issue only; do not create rename or title-patch plan items |
### Plan Generation Progress Reporting
Plan generation can be long-running when `resource_items` is large or source-container parent / child move ordering is complex.
Rules:
1. If plan generation starts with more than 500 `resource_items`, output one concise start notice with the resource count and that no write operation is being executed.
2. If plan generation runs longer than about 60 seconds, output progress about every 60 seconds.
3. Progress reports SHOULD include only fields currently known: processed resource count, generated plan item count, create count, move count, source-container move count, review count, and current step.
4. Do not display unpaginated plan details as progress. Complete `plan_items` remain internal until the normal paginated output.
5. Do not ask the user to continue during plan generation unless auth, permission, API, target scope, or environment blockers occur.
6. Do not output filler such as "still running" without current counts or current step.
Example:
```text
计划生成进度:已处理 <processed_count>/<resource_count> 项资源,生成 <plan_item_count> 项计划,其中创建 <create_count> 项、移动 <move_count> 项。继续计算父子目录移动顺序,不会执行创建或移动。
```
## PlanItem
`PlanItem` is for internal execution. It may contain tokens and internal enums.
@@ -167,11 +187,11 @@ Confidence display map:
- 低置信度:<low_count> 项
你可以选择:
- 查看第 1 页明细
- 只看将创建的目录 / 节点
- 只看待人工确认项
- 只看高置信度移动项
- 进入执行确认
1. 查看第 1 页明细
2. 只看将创建的目录 / 节点
3. 只看待人工确认项
4. 只看高置信度移动项
5. 进入下一步:确认执行计划
```
If `total_count > 500`, say:
@@ -224,10 +244,10 @@ User-facing output:
说明:后续执行默认基于这份完整修正版计划,不是只执行刚才的修正项。
你可以选择:
A. 查看修正版计划总览
B. 查看本次修改涉及的资源
C. 进入执行确认
D. 继续调整
1. 查看修正版计划总览
2. 查看本次修改涉及的资源
3. 进入下一步:确认执行计划
4. 继续调整
```
If the user explicitly asks to execute only the corrected items, ask for confirmation before execution:
@@ -248,15 +268,15 @@ If the user explicitly asks to execute only the corrected items, ask for confirm
还有 <remaining_pages> 页未展示。
你可以回复:
- 继续看下一页
- 只看待人工确认项
- 只看低置信度项
- 进入执行确认
1. 继续看下一页
2. 只看待人工确认项
3. 只看低置信度项
4. 进入下一步:确认执行计划
```
## State: EXEC_CONFIRM
Entry: user asks to execute.
Entry: user asks to view execution confirmation or continue toward execution.
MUST:
@@ -284,17 +304,17 @@ Before execution confirmation, MUST show this notice:
When the user wants execution, ask for execution scope:
Execution confirmation options MUST be renumbered by currently available choices. Do not show disabled choices, and do not ask the user to reply with skipped letters.
Execution confirmation options MUST be numbered by currently available choices. Do not show disabled choices, and do not ask the user to reply with skipped numbers.
If a plan detail page is currently active:
```text
请确认执行范围:
A. 执行完整计划:<total_count> 项
B. 只执行当前页:<current_page_count> 项
C. 只执行高置信度项:<high_confidence_count> 项
D. 暂不执行,只保留方案
1. 执行完整计划:<total_count> 项
2. 只执行当前页:<current_page_count> 项
3. 只执行高置信度项:<high_confidence_count> 项
4. 暂不执行,只保留方案
本 workflow 只执行已确认范围内的创建、移动和必要的单资源权限申请;不会重命名任何资源。
```
@@ -304,9 +324,9 @@ If no plan detail page is currently active:
```text
请确认执行范围:
A. 执行完整计划:<total_count> 项
B. 只执行高置信度项:<high_confidence_count> 项
C. 暂不执行,只保留方案
1. 执行完整计划:<total_count> 项
2. 只执行高置信度项:<high_confidence_count> 项
3. 暂不执行,只保留方案
如需只执行某一页,请先查看计划明细页。

View File

@@ -89,7 +89,8 @@ Agent MUST maintain these internal fields during one workflow run:
| `environment_profile` | Current environment and CLI profile, such as prod / BOE / PRE and config profile |
| `identity` | `user` by default unless user explicitly asks for app / bot perspective |
| `resource_items` | Complete normalized resource list from discovery |
| `partial` | Whether inventory or content-read limits were hit |
| `partial` | Whether inventory or content read cannot fully continue because of auth, permission, API / pagination failure after retries, API coverage limitations, tool budget, or scope blockers; batching checkpoints alone are not partial |
| `inventory_continuation_state` | Structured checkpoint for continuing inventory batches within the confirmed scope. Must preserve `scope`, `queue`, `current_cursor`, `visited_page_keys`, `dedupe_keys`, and `blockers`; Drive queue entries carry `folder_token`, `path`, `depth`, and `page_token`; Wiki queue entries carry `space_id` / `node_token`, `path`, `depth`, and pagination cursor; search entries carry query / filters and pagination cursor. Missing or corrupt state is a blocker, not a completed inventory. |
| `low_confidence_items` | Items requiring mandatory partial content read |
| `issue_summary` | Problem types, counts, evidence paths, and suggested handling |
| `classification_rules` | Rules used to map resources to target paths |
@@ -211,6 +212,7 @@ Never request permission automatically, never batch permission requests, and nev
- [Rollback phase](lark-drive-workflow-knowledge-organize-rollback.md)
- [lark-shared](../../lark-shared/SKILL.md)
- [lark-drive](../SKILL.md)
- [lark-drive-files-list](lark-drive-files-list.md)
- [lark-drive-search](lark-drive-search.md)
- [lark-drive-inspect](lark-drive-inspect.md)
- [lark-drive-apply-permission](lark-drive-apply-permission.md)

View File

@@ -1,7 +1,7 @@
---
name: lark-sheets
version: 2.0.0
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。仅针对飞书在线电子表格,不适用于本地 Excel 文件。"
description: "飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模DCF、三张表、预算、Sensitivity 等)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
metadata:
requires:
bins: ["lark-cli"]
@@ -40,18 +40,22 @@ metadata:
| --- | --- | --- |
| 读数据(纯值 / CSV | `+csv-get`(范围用 `--range` | — |
| 读值 + 公式 / 样式 / 批注 | `+cells-get --include value,formula,style,comment,data_validation` | `--with-styles``--with-merges``--include-merged-cells` |
| 写纯值(整块 CSV 平铺) | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
| 写纯文本值(整块 CSV 平铺,列里没有需保留的数值 / 日期语义 | `+csv-put`(定位用 `--start-cell`,单个左上角锚点格;也接受 `--range` 别名,区间自动取左上角) | — |
| 写带类型的数据到**已有**表(列里有数字 / 金额 / 百分比 / 日期 / 计数,要可排序 / 求和 / 入图表 / 透视) | `+table-put`(列显式声明 `type` + `format`,类型保真;来源不限 DataFrame——Counter / dict / list 同理,详见 write-cells | 在本地把数字拼成 `"$1,234"` / `"30.5%"` 字符串再 `+csv-put`(会落成文本、丢失计算能力) |
| **新建**电子表格并写带类型的数据(类型保真需求同上,但目标表还不存在) | `+workbook-create --sheets`(协议与 `+table-put` 同构、一步建表 + typed 写入,无需先建空表再 `+table-put`date / number 不丢,详见 workbook | 用 `--values` 灌日期 / 数字(会落成文本、丢类型) |
| 写值 / 公式 / 样式 | `+cells-set`(定位用 `--range` | — |
| 查找单元格 | `+cells-search`(关键字用 `--find` | `+cells-find``+find``--query` |
| 查找并替换 | `+cells-replace` | — |
| 看子表结构(合并 / 行高列宽 / 冻结 / 隐藏) | `+sheet-info` | `+sheet-get``+structure-get``+sheet-structure-get` |
| 看工作簿 / 子表清单 | `+workbook-info` | — |
| 导出 xlsx / 单表 csv | `+workbook-export` | — |
| 导入本地 xlsx/xls/csv 文件为新表格 | `+workbook-import --file ./x.xlsx`(仅导入为电子表格;要导成多维表格走 `drive +import --type bitable` | 把 .xlsx 在本地读成数据再 `+workbook-create` 重灌(丢原格式、低效) |
| 清除内容 / 格式 | `+cells-clear`(范围维度用 `--scope`,取值 content / formats / all | `--type` |
| 批量清除多区域 | `+cells-batch-clear``--scope` | `--target` |
| 调整列宽 / 行高 | `+cols-resize` / `+rows-resize`(行、列是两个独立命令) | `--dimension`(无此 flag |
| 分组汇总 / 透视 | `+pivot-create`(默认不传落点 flag → 自动新建子表,零覆盖) | 用 SUMIF / 本地脚本拼一张假透视表 |
> ⚠️ **纯文本还是数值语义**:要写的列里有数字 / 金额 / 百分比 / 日期 / 计数 → `+table-put`(写入已有表;声明 `type` + `format`,保留排序 / 求和 / 图表 / 透视能力;**目标表还不存在就用 `+workbook-create --sheets`**,同 typed 协议、一步建表 + 写入,别先建空表再 `+table-put`);只有纯文本才用 `+csv-put`。两者写完显示可以完全相同,但 `+csv-put` 落的是文本、不能参与计算——别把数值在本地拼成带 `$` / `%` 的字符串再走 `+csv-put`。
> ⚠️ **定位 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。
@@ -63,28 +67,30 @@ metadata:
| Reference | 描述 |
| --- | --- |
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。不适用于本地 Excel 文件操作。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。仅针对飞书表格,不适用于本地 Excel 文件。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开时使用。仅针对飞书在线表格,不适用于本地 Excel 文件执行。 |
| [飞书表格核心操作:分析、编辑与可视化](references/lark-sheets-core-operations.md) | 飞书表格核心操作工作流。当用户需要对已有的飞书表格进行查看、分析、编辑或可视化时使用。适用场景:数据查询与统计、公式计算、表格美化、创建图表/透视表、筛选排序、批量修改数据、调整表格结构等。即使用户没有明确说"飞书表格",只要操作对象是已有的在线表格,都应触发此工作流。 |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框等取值标准,以及新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式高亮、标红、数据条、色阶请使用 lark-sheets-conditional-format。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开时使用。 |
| [飞书表格金融/财务建模规范](references/lark-sheets-financial-modeling-standards.md) | 飞书表格金融/财务/估值建模场景的专业结构与视觉规范DCF / LBO / Comps / Precedent、三张财务报表、预算与 Variance Analysis、Sensitivity / Scenario、FP&A 等。覆盖科目分类、三表勾稽、Assumptions / Calc / Sensitivity 拆分、年份横向布局、假设颜色编码、财务数字格式、Sensitivity 禁色阶等。与通用视觉规范冲突时以本文为准;用户明确样式或既有模板优先。 |
### 按对象的工具参考(含 shortcut
| Reference | 描述 |
| --- | --- |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。仅针对飞书表格。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。仅针对飞书表格。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。仅针对飞书表格。 |
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。仅针对飞书表格。 |
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image若只需把一块 CSV 纯值批量铺到表格上(不带公式/样式),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。仅针对飞书表格。 |
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。仅针对飞书表格。 |
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。仅针对飞书表格。 |
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。仅针对飞书表格。 |
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。仅针对飞书表格。 |
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。仅针对飞书表格。 |
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器filter。当用户需要筛选数据按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。仅针对飞书表格。 |
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。仅针对飞书表格。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。仅针对飞书表格。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。仅针对飞书表格。 |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局。适用场景:查看行高、列宽、隐藏行列、合并单元格等布局信息,以及"插入一行"、"删除这列"、"隐藏行"、"冻结表头"、行列分组(大纲折叠/展开)等操作。行列大纲仅在用户明确提到"行分组"、"列分组"、"大纲"、"outline"时才触发,"按XXX分组"等数据分组场景请使用 lark-sheets-pivot-table。如需在表尾追加数据应先通过此 skill 插入行,再通过 lark-sheets-write-cells 写入。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格的指定区域批量写入值、公式、样式、批注或单元格图片。适用场景:填写数据、设置公式、修改格式、添加批注、嵌入单元格图片(如需操作浮动图片,请使用 lark-sheets-float-image若只需把一块 CSV 批量铺到表格上(值或公式,不带样式/批注),直接使用 `+csv-put` 更短更快。追加数据需先通过 lark-sheets-sheet-structure 插入行列。 |
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。 |
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器filter。当用户需要筛选数据按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。 |
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图filter view。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器filter相互独立可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
| [Lark Sheet Recover](references/lark-sheets-recover.md) | 整表回退到历史版本(方案 B。当用户提到"恢复到之前的版本"、"撤销所有改动回到某个状态"、"整表回滚"、"recover"时使用。这是全文档回退(丢弃目标版本之后的所有编辑,含他人改动)——要精确逐笔撤销本链路最近编辑,用 +undo。 |
## 公共 flag 速查
@@ -102,7 +108,7 @@ metadata:
1. **spreadsheet 定位(必填)**`--url``--spreadsheet-token` 二选一,**必须给其中之一**。两个都不给 → 校验报错 `specify at least one of --url or --spreadsheet-token`;两个都给 → 互斥冲突。
- **`--url` 只解析 `/sheets/``/spreadsheets/` 两种链接**(从路径里抽出 token也可以直接把裸 token 传给 `--spreadsheet-token`)。其它形态的链接不会被解析成表格 token。
- ⚠️ **`/wiki/` 知识库链接不能直接当表格定位用**wiki 链接背后可能是电子表格,也可能是文档 / 多维表格等其它类型,`--url` **不会**自动把 wiki token 解析成 spreadsheet token直接传会失败。必须先把它解析成真实文档 token —— `lark-cli wiki +node-get --node-token "<wiki 链接或 token>"`,确认返回的 `obj_type``sheet` 后,取其 `obj_token` 作为 `--spreadsheet-token` 传入(解析细节见 [`../lark-wiki/SKILL.md`](../lark-wiki/SKILL.md))。
- **例外**`+workbook-create` 是新建一个还不存在的表格,**不接受任何 spreadsheet / sheet 定位 flag**只有 `--title` / `--folder-token` / `--headers` / `--values`
- **例外**`+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`,否则照样报上面的错。

View File

@@ -36,7 +36,8 @@
- **默认情况inline 模式)**`refs` 范围**应包含表头行**(首行/首列即系列名),且范围要精确覆盖目标数据,不要多选或少选。
- **合并标题行要跳过**:如果表格在表头上方存在合并的标题行(如"员工统计表"横跨多列的大标题),`refs` 必须跳过标题行、从真正的列标题行开始。例如表头在第 3 行、数据在第 4-20 行,则 `refs` 应为 `A3:G20` 而非 `A1:G20`。包含合并标题行会导致列名识别错误、表头被当作数据参与聚合计算。
- **数据与表头分离时必须用 detached 模式**:当 `refs` 只覆盖完整数据的一个子集(按筛选/分组只画其中一段),而真正的语义表头在该子集之外时,**必须**设置 `snapshot.data.headerMode='detached'`refs 仅传纯数据范围,维度名/系列名通过 `snapshot.data.dim1.serie.nameRef` / `snapshot.data.dim2.series[].nameRef` 指向真正的表头单元格。详见下文"硬性规则:数据与表头分离场景必须使用 detached 模式"。
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format``number_format`schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。
- **axes[].label 不接受 `format` / `number_format` 字段**:想给坐标轴数值加千分位、百分号等格式化时,不要在 `axes[i].label` 里传 `format``number_format`schema 未定义,会报 `unexpected property "format" is not defined in schema`)。数值格式化统一在源数据单元格的 `cell_styles.number_format` 里设置(写 `+cells-set` 时),图表会沿用单元格格式。**日期轴同理**:横轴显示成 `45297` 这类 Excel 序列号,是因为源日期列没设日期格式——给源列设 `number_format="yyyy-mm-dd"` 后横轴才会显示成日期(反例:折线图横轴日期显示为序列号)。大数值轴显示科学计数法同理,给源列设整数 / 千分位格式(反例:透视表数值轴显示科学计数法)。
- **轴口径要对齐用户要的指标**:用户要"占比 / 比例"时,**纵轴应是百分比**——用饼图,或柱 / 条形图设 `stack.percentage: true` 让纵轴变 %,并把数据源指向占比列 / 让数据标签显示百分比;不要交付纵轴仍是原始计数的图(反例:要求看各类占比,却用普通堆积柱、纵轴是 0350 的人数而非百分比)。
- **创建后必须验证**:图表创建后必须调用 `+chart-list` 验证配置是否正确
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置 `dim1`/`dim2` 的 `index`。**
@@ -84,15 +85,17 @@
1. **查尺寸**`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount`+sheet-info` 只返回布局,不含行列总数)。
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**`needCols = ceil(width/105)``needRows = ceil(height/27)`
3. **校验**`position.row + needRows ≤ rowCount``col_idx + needCols ≤ columnCount`col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
3. **校验**`position.row + needRows ≤ rowCount``col_idx + needCols ≤ columnCount``position.row`**0-based**:首行 = `row:0`,与 A1 区间 / `+dim-insert --position` 的 1-based 行号不同;col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`
- 否则先调 `+dim-insert``lark-sheets-sheet-structure`)扩行/列,再 create。
⚠️ **图表落点禁止压在已有数据矩形内**——必须落在数据区**右侧或下方的空白**,否则图表浮层会遮挡原始数据被判失败(反例:折线图落在数据区中间,遮挡了下方原始数据)。
**示例**21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
-`{row: 0, col: "W"}` — col=22 越界
-`{row: 42, col: "A"}` — 放数据下方
- ✅ 先 `+dim-insert --dimension column --start 21 --end 27`(在 U插 6 列U=index 20after 即从 21 起),再放图到 `{row: 0, col: "V"}`
- ✅ 先 `+dim-insert --position V --count 6`(在 V插 6 列,即 U 列之后),再放图到 `{row: 0, col: "V"}`
## Shortcuts
@@ -147,9 +150,9 @@ _公共四件套 · 系统:`--yes`、`--dry-run`_
_创建/更新的图表属性_
**顶层字段**
- `position` (object) — 必填 { row: number, col: string }
- `position` (object?) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object) — 必填 { width: number, height: number }
- `size` (object?) — 必填 { width: number, height: number }
- `snapshot` (object?) — 图表快照配置 { title?: object, subTitle?: object, style?: object, legend?: oneOf, plotArea: object, …共 6 项 }
## Examples
@@ -164,24 +167,28 @@ _创建/更新的图表属性_
> **`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`,即便侥幸通过也只会渲染空图。
> ⚠️ **含 `'Sheet'!` 前缀的 `--properties` 必须走 stdin 或 `@file`,不要用 inline 单引号**。`refs` / `nameRef` 里的 sheet 前缀带单引号(`'Sheet1'!A1`),若塞进 inline 的 `--properties '{...}'`bash 会把内层那对单引号吃掉sheet 名带空格还会被拆成多个词JSON 直接被破坏。下面示例统一用 `--properties - <<'JSON' … JSON`heredoc 定界符加引号 = 不做 shell 替换),或 `--properties @file.json``@` 只接 cwd 下相对路径)。
最小可用列图inline 模式refs 含表头行):
```bash
lark-cli sheets +chart-create --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --properties '{
"position":{"row":42,"col":"A"},
"size":{"width":600,"height":400},
"snapshot":{
"data":{
"refs":[{"value":"'Sheet1'!A1:B10"}],
"dim1":{"serie":{"index":1}},
"dim2":{"series":[{"index":2}]}
},
"plotArea":{"plot":{"type":"column"}}
}
}'
--sheet-name "Sheet1" --properties - <<'JSON'
{
"position":{"row":42,"col":"A"},
"size":{"width":600,"height":400},
"snapshot":{
"data":{
"refs":[{"value":"'Sheet1'!A1:B10"}],
"dim1":{"serie":{"index":1}},
"dim2":{"series":[{"index":2}]}
},
"plotArea":{"plot":{"type":"column"}}
}
}
JSON
# 走文件(推荐配置较多时)
# 或落到 cwd 下相对路径文件再用 @file
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @chart-config.json
```
@@ -190,7 +197,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties @ch
饼图比 column / bar 更复杂:`sectors` 是 object里面再包一个**单数** `sector` 数组——CLI 不替你 normalize写错路径会被 server schema 直接拒。
```bash
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties - <<'JSON'
{
"position":{"row":24,"col":"F"},
"size":{"width":600,"height":450},
"snapshot":{
@@ -208,7 +216,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
"dim2":{"series":[{"index":2,"aggregateType":"sum"}]}
}
}
}'
}
JSON
```
**数据与表头分离(必须用 `detached` + `nameRef`**
@@ -216,7 +225,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet1" --properties '{
场景:周度销量明细表,真实表头在第 1 行A1=周次、C1=订单量、D1=退款量),数据按 B 列"店铺"分段;用户只要"3 号店"那一段(第 1117 行)。
```bash
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties - <<'JSON'
{
"position":{"row":7,"col":"F"},
"size":{"width":600,"height":360},
"snapshot":{
@@ -233,7 +243,8 @@ lark-cli sheets +chart-create --url "..." --sheet-name "Sheet2" --properties '{
]}
}
}
}'
}
JSON
```
约束:

View File

@@ -4,29 +4,30 @@
面向"已有飞书表格"的核心工作流,核心原则:**先了解,再分析或写入,最后验证**。本文是方法论总纲;具体工具的参数细节、边界陷阱在对应子 skill本文用指针引到那里不重复展开。
**份「通用方法与规范」如何分工**(都不含 shortcut按主题单一归属
**份「通用方法与规范」如何分工**(都不含 shortcut按主题单一归属
- **本文core-operations= 流程与铁律**:端到端工作流 + 全局铁律 + 横切陷阱,是读取入口与枢纽。
- **`lark-sheets-visual-standards` = 样式知识**:配色 / 表头 / 数值格式 / 斑马纹 / 美化决策等"正确视觉输出"的全部标准。
- **`lark-sheets-formula-translation` = 公式知识**:飞书公式书写与 Excel 迁移的全部正确性规则(绝对引用、范围语法、数组语义、不支持函数等)。
- **`lark-sheets-financial-modeling-standards` = 金融/财务建模知识**DCF / 三张表 / 预算 / Sensitivity 等专业模型的结构、公式、颜色编码、数字格式与验收规则;与通用视觉规范冲突时以金融规范为准。
> **下面的铁律对所有任务一律生效**,即使你是被索引直接路由进 visual 或 formula 而没经过本文——编辑类任务务必先回到这里过一遍铁律。
## 铁律(所有编辑类任务必须满足,子 skill 不得放宽)
1. **最小改动**:除用户明示要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet新建允许节制使用
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。
1. **最小改动**:除用户明示要改的单元格 / 列外原表其它单元格、行列结构、Sheet 名、合并区、格式必须 1:1 保持。中间结果优先放原数据**右侧**;会与原数据混淆或要承载透视表 / 图表时才**新建空白 Sheet**。**禁止**擅自删 / 改名 / 隐藏 / 移动**已存在**的 Sheet新建允许节制使用**改写 / 转换类任务要精确圈定适用行列**:只对任务真正要求的对象做变换,**不该转的行 / 列保持原值 1:1**(典型反例:要求"统一翻译"时把本就是中文、应原样保留的评论也重新翻译;要求"改写某列格式"时连原始测量值也一并改动 → 应保留的原文被篡改)。
2. **真实写回 + 回读校验**:交付必须是对在线表格的真实写入,并 `+csv-get` / `+cells-get` / `+<对象>-list` 回读校验。**严禁**只在文本里描述"已完成"、用普通公式 / 文本假装结构化对象、或只给占位而无真实写入。**收尾前必须确认产物文件真实存在 / 可导出**——别在没真正生成产物时只凭文本"已完成"就结束(反例:文本称已完成,实际没生成产物文件,等于没交付)。
3. **读全再写,禁止只探前 N 行**:批量填充 / 补齐 / 修正类任务必须先确认**真实数据末行**再写,否则会漏写表尾(高频致命错误)。完整的"按表格形态分流读取 + `current_region` / `has_more` 兜底 + 真实末行确认"流程见 `lark-sheets-read-data` 的「确定数据范围的正确流程」。
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具SORT / `TEXTBEFORE` / `MID` / 透视表 等。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。
4. **公式优先于硬编码**:能用飞书公式表达的计算(总计 / 占比 / 增长率 / 提取 / 查找等)一律写公式而非静态值,源数据变化才能自动重算。用户口头的"分列 / 排序 / 求和 / 提取"也要落地为公式或原生工具SORT / `TEXTBEFORE` / `MID` / 透视表 等。Excel 公式迁移、数组语义、不支持函数清单一律以 `lark-sheets-formula-translation` 为唯一权威。**即使用户没说"联动 / 自动更新",凡是可由表内其它单元格推导的派生值(年龄=当年-出生年、占比=本类数/总数、达标=阈值判断、排名、各类分组汇总)默认就必须用公式**——用户默认期望派生列能随源数据重算,**离线 Python / 脚本算完写静态值,即便当前数值正确,改了源数据也不会自动更新,等于没满足"派生"的本意**(反例:年龄、月度汇总、占比、分组求和等派生列写死值,源数据一改结果就过时)。
5. **续写 / 扩展必须继承样式**:续写、补齐、复制区块、新增行列时,**禁止**只读值只写值。必须连带 `cell_styles` + `border_styles` + 合并 + 行高一起继承。完整继承清单与做法见 `lark-sheets-write-cells` 的「新增列 / 新增行的样式继承」(`border_styles` 四边最易漏)。
6. **多步写入优先 `+batch-update`**:多个连续写入、或同一工具对多个区域重复调用(多次 merge / resize / cells-set必须合并为单次原子 `+batch-update`。语义与不可嵌套的限制见 `lark-sheets-batch-update`
7. **分组汇总必须用透视表**"按 X 统计 Y / 分组汇总 / 各部门数量金额"必须用 `+pivot-{create|update|delete}`(推荐省略 sheet_id 自动新建子表),**禁止**用 SUMIF / COUNTIF 或本地脚本覆盖原表替代。
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert多目标删 N 行每目标一个多格式兼容多种日期格式每种至少一个样本范围类A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。
8. **任务拆成可验证 checklist**:落地前把指令拆成所有"独立可验证子要点",每点一个 `assert`,全部通过才交付:多维度操作(按部门一/二/三级排序)每维一个 assert多目标删 N 行每目标一个多格式兼容多种日期格式每种至少一个样本范围类A1:H11 加边框)起 / 末行 / 末列三边界都核。只完成第一个要点(只排一级、只删 1 行)属违规。**题面 / 表头里写明的格式规范也是子要点**:表头注明"需标注某字段"就必须给对应单元格加规定前缀并逐条 assert 前缀存在(反例:漏加规定前缀,该要点即不达标);"相同编号连续行合并"必须遍历所有相同编号组全部合并(反例:只合并了其中一部分组)。
9. **全量处理要前置断言条数**:翻译 / 打标 / 批量公式落地等逐条任务,落地前把"预期处理条数"硬编码进代码,处理完 `assert actual == expected`。**严禁**输出"已完成前 N 条,剩余将继续"的半成品。
## 推荐工作流程
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill避免读一个调一个本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要。
1. **规划 skill 清单**:开工前一次性列出本任务要读的子 skill避免读一个调一个本轮已读过的不重复读。本 skill + `lark-sheets-workbook` 几乎每次都要;任务涉及 DCF、三张表、预算、Variance、Sensitivity、估值、FP&A 等金融/财务建模时,必须额外读取 `lark-sheets-financial-modeling-standards`
2. **了解结构**:先 `+workbook-info` 拿子表列表 / 行列数 / 冻结位置(不可猜测,猜错会越界覆盖);涉及合并 / 隐藏 / 分组 / 行高列宽再用 `lark-sheets-sheet-structure``+sheet-info`
3. **读取数据(按任务类型选路径,细则见 `lark-sheets-read-data`**
@@ -67,6 +68,7 @@
- **喂给 CLI 的 CSV / JSON 用 UTF-8、不带 BOM**BOM 会污染首格的值或触发 `invalid character` 解析错;脚本读写文件时显式指定 `encoding='utf-8'`
- **临时文件交给运行时的标准库**:用 `tempfile.gettempdir()` / `os.tmpdir()` 等取临时目录,不要硬编码固定路径;放在用户项目目录之外。
- **命令失败先读错误再调整**:同一条命令失败后不要原样重发;先看 stderr 的报错(参数错误、缺依赖、解释器不可用等)定位原因,再决定换写法、补依赖或退回原生工具。
- **写回的必须是纯单元格值,禁止把"值+样式标注"串当值写回**:本地脚本或某些 xlsx 解析库会把单元格渲染成 `甲方支行(V-Align: bottom)` 这种"值(样式)"字符串CSV 字段还可能带包裹双引号。回写前必须**剥离括号样式标注、去掉残留引号**,只写原始值——否则样式描述会变成单元格的字面文本污染原数据(反例:排序后单元格值里被写进 `(V-Align: bottom)` 这类样式后缀文本,末尾还多一个双引号)。**排序本身优先用 `+range-sort` 原生工具**,不要"读出来本地排完再整列写回",从根上避免这类回写污染。
## 公式策略

View File

@@ -109,6 +109,17 @@ lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
```
**`conditions[].type` × `compare_type` 取值**`type` 决定可用的 `compare_type`;两者均必填):
| `type` | 可用 `compare_type` | `values` |
|---|---|---|
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
> ⚠️ `text` 用 `equals` / `notEquals`**带 s**`number` / `multiValue` 用 `equal` / `notEqual`**不带 s**)——别混。完整 schema 跑 `+filter-view-create --print-schema --flag-name properties`。
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
### `+filter-view-update`

View File

@@ -102,6 +102,17 @@ lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
```
**`conditions[].type` × `compare_type` 取值**`type` 决定可用的 `compare_type`;两者均必填):
| `type` | 可用 `compare_type` | `values` |
|---|---|---|
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
> ⚠️ `text` 用 `equals` / `notEquals`**带 s**`number` / `multiValue` 用 `equal` / `notEqual`**不带 s**)——别混。完整 schema 跑 `+filter-create --print-schema --flag-name properties`。
### `+filter-update`
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。

View File

@@ -0,0 +1,332 @@
# 飞书表格金融/财务建模规范
## 定位与优先级
本文用于飞书在线表格中的金融、财务、估值和经营分析场景,包括 DCF / LBO / Comps / Precedent、三张财务报表、预算与 Variance Analysis、Sensitivity / Scenario Analysis、资本结构、Unit Economics、Market Sizing、FP&A 和投研/投行/PE/咨询模型。
优先级从高到低:
1. 用户明确指令或既有模板样式。
2. 本金融/财务建模规范。
3. 通用核心操作、视觉规范与公式规则。
如果本文与通用视觉规范冲突,以本文为准。典型冲突:
| 通用规范 | 财务模型规范 |
| --- | --- |
| 数据行较多时可使用斑马纹 | 财务模型默认禁止斑马纹,避免干扰小计/合计识别 |
| 可用数据条、色阶强化可视化 | Sensitivity 禁止色阶、数据条和图标集 |
| 列宽按内容自适应 | 年份列必须紧凑等宽;标签列才加宽 |
| 可用柔和竖线分隔区域 | 普通财务模型禁止竖线Sensitivity 矩阵浅灰细框是唯一例外 |
## 按任务裁剪
不要把全套 DCF 规范套到简单费用表。先判断任务类型,再选择规则。
| 任务类型 | 必用规则 | 不要做 |
| --- | --- | --- |
| 三表整理 / 标准化 | 科目分类、三表结构、勾稽验证、颜色编码、数字格式、单位、年份横向布局、小计/合计边框 | 不需要 Sensitivity |
| DCF / LBO / 估值模型 | 假设/计算/输出分层,多 sheet 拆分横向年份假设单独落地公式引用假设Sensitivity 按需独立 | 不要把假设硬编码进公式 |
| Comps / Precedent | 科目口径、颜色编码、数字格式、单位、分区和小计样式 | 通常不需要三表勾稽,不强制拆多 sheet |
| 预算 / Variance Analysis | 颜色编码、数字格式、横向期间、差异列公式、汇总边框 | 不需要 Terminal Value / WACC |
| 简单 FP&A 汇总 / 费用表 | 基础颜色编码、数字格式、单位、合理列宽 | 不强行拆 sheet不强行做三层边框 |
| 单项 Sensitivity / Scenario | 假设分离、Sensitivity 专用视觉规范、baseline 标注 | 不使用色阶/数据条 |
## Pandas / DataFrame 落地路径
金融和财务任务经常先用 Python / pandas 做清洗、分组、透视、回归、敏感性计算或估值汇总,再把 DataFrame 写回飞书表格。只要结果列里有金额、百分比、日期、计数、倍数等数值语义,默认走 typed 表格协议,不要先把 DataFrame 格式化成 CSV 字符串。
按目标选择写入命令:
| 目标 | 命令 | 用法 |
| --- | --- | --- |
| 写入已有 spreadsheet | `+table-put --sheets` | 把 DataFrame 转成 `{sheets:[...]}`,按 sheet 名匹配,缺 sheet 时创建,支持覆盖 / 追加 |
| 新建 spreadsheet 并写入结果 | `+workbook-create --sheets` | 协议与 `+table-put` 同构,一步建表 + typed 写入,适合 pandas 算完直接交付新模型 |
typed payload 结构:
```json
{
"sheets": [
{
"name": "Output",
"start_cell": "A1",
"mode": "overwrite",
"columns": [
{"name": "Date", "type": "date", "format": "yyyy-mm-dd"},
{"name": "Revenue", "type": "number", "format": "$#,##0;($#,##0);\"-\""},
{"name": "EBITDA Margin", "type": "number", "format": "0.0%"}
],
"rows": [
["2026-12-31", 708000000, 0.29]
]
}
]
}
```
DataFrame 转 payload 时按业务语义定列类型:
- 金额、收入、费用、利润、人数、股数、倍数、百分比都用 `type:"number"`;百分比存小数,如 `12.5%``0.125`,靠 `format:"0.0%"` 显示。
- 日期列用 `type:"date"`,值用 ISO 日期字符串;不要把日期预格式化成普通文本。
- 订单号、股票代码、员工编号等需要保留前导零或不参与计算的字段用 `type:"string"`
- pandas 计算出的源数据 / 输出表先用 `+table-put``+workbook-create --sheets` 落地公式、颜色编码、边框、Sensitivity baseline 高亮再用 `+cells-set` / `+cells-set-style` 补。
## 财务逻辑规范
### 科目标准分类
整理原始科目时,不能把 raw data 机械拼接成报表。必须按 GAAP / IFRS 和财务建模惯例分类。
Income Statement 关键分类:
| 分类 | 示例 | 高频错误 |
| --- | --- | --- |
| Revenue | Product Revenue、Service Revenue、Subscription、License | 把 Other Income 混入主营收入 |
| COGS | 直接人工、直接材料、制造费用、SaaS hosting/infrastructure、支付处理费 | 把 Implementation SG&A / Customer Success 误归 COGS |
| SG&A | G&A、Sales & Marketing、Implementation SG&A、Customer Success、Operations SG&A、Shared Service | 把 Marketing 误归 COGS漏掉分摊费用 |
| R&D | Research、Product Development、Engineering Payroll | 并入 G&A |
| D&A | Depreciation、Amortization | 埋在 COGS 或 SG&A 里 |
| Non-operating | Interest、FX、One-time Items | 和 Operating Income 混淆 |
| Tax | Income Tax Expense / Benefit | 当作经营费用 |
Balance Sheet 按 Current / Non-current 拆分 Assets、Liabilities、EquityCash、AR、Inventory、Prepaids 属 Current AssetsPP&E / Goodwill / Intangibles 属 Non-current AssetsAP / Accrued / ST Debt / Deferred Revenue 属 Current LiabilitiesLT Debt / Deferred Tax 属 Non-current Liabilities。
Cash Flow Statement 使用间接法Net Income 起步,加回 D&A / SBC调整 Working Capital 和 Deferred Tax分 CFO / CFI / CFF最终 Ending Cash 必须等于 BS Cash。
### 标准报表结构
Income Statement 自上而下:
```text
Revenue
Total Revenue
- COGS
Total COGS
= Gross Profit
Gross Margin %
- Operating Expenses
Total OpEx
= EBITDA
EBITDA Margin %
- D&A
= EBIT
- Interest / Other Income (Expense)
= Pre-tax Income
- Tax
= Net Income
Net Income Margin % / EPS
```
Balance Sheet 必须有 `Total Assets``Total Liabilities``Total Equity``Total Liabilities & Equity``Check: Total Assets - Total L&E = 0`
Cash Flow Statement 必须有 CFO、CFI、CFF、Net Change in Cash、Beginning Cash、Ending Cash并校验 Ending Cash = BS Cash。
DCF 标准骨架:
```text
1. Key Assumptions
2. Revenue / EBITDA / EBIT / NOPAT
3. + D&A - CapEx - Change in NWC = UFCF
4. Discount Factor / PV of UFCF
5. Terminal Value
6. Enterprise Value -> Equity Value -> Implied Share Price
7. Sensitivity / Scenario
```
### 多 sheet 拆分
专业模型按 Input -> Calc -> Output 分层,复杂模型必须拆分:
- DCF / LBO`Assumptions``DCF - Calc``Sensitivity``Output / Summary`,可选 `Source Data`
- 三表模型:`Assumptions``IS``BS``CFS``Supporting Schedules``Check`
- 带 Sensitivity 的任何模型Sensitivity 必须独立 sheet 或独立清晰区块。
简单报表整理、费用汇总、Variance Analysis、单页 Comps/Precedent、总行数小于 40 的单一逻辑块可以不拆 sheet。
跨 sheet 引用规则:
1. 引用路径写完整 sheet 名,如 `='Assumptions'!$B$7`sheet 名含空格或特殊字符时按飞书公式规则加引号。
2. 数据流单向:`Assumptions -> Calc -> Sensitivity -> Output`
3. 禁止循环引用Sensitivity 直接引用 Calc 结果,不链式穿透多个中间 sheet。
### 年份横向布局
时间必须横向排布,科目纵向排布。所有相关 sheet 的年份列必须对齐。
正确示例:
```text
FY2023A FY2024A FY2025E FY2026E
Revenue 500 575 644 708
EBITDA 125 155 180 205
```
假设值如果按年度变化,也必须横向排布并与计算 sheet 的年份列一一对齐:
```text
FY2025E FY2026E FY2027E
Revenue Growth % 12.0% 10.0% 9.0%
EBITDA Margin % 25.0% 26.0% 27.0%
CapEx % of Revenue 5.0% 5.0% 4.5%
Tax Rate 25.0% 25.0% 25.0%
```
永续性假设如 WACC、Terminal Growth 可以单独放在 Assumptions 上方,不按年份展开。
### 假设值与公式
可被用户修改的假设必须集中放在 Assumptions 区或 sheet用蓝色字体标识并由公式引用。禁止把 Growth、Margin、WACC、Tax Rate、CapEx %、Terminal Growth、倍数等硬编码在公式里。
只有三类单元格可直接写静态值:
1. 历史真实数据。
2. 蓝色输入假设。
3. 外部来源的静态取数,且必须标注来源。
横向拉公式时必须正确使用 `$`
| 被引用内容 | 正确模式 | 说明 |
| --- | --- | --- |
| 同行逐年变化值 | `A1` | 向右复制时跟随年份变动 |
| 单一永续假设 | `$B$7` | 向右/向下都锁定 |
| 同列假设、逐行变化 | `$B7` | 锁列不锁行 |
| 年份标题行 | `B$4` | 锁行不锁列 |
写完横向公式后,必须回读 2-3 个相邻年份列的公式字符串和值,确认年份引用跟随列移动、被锁定的假设保持不变,且没有 `#REF!``#DIV/0!``#VALUE!``#NAME?``#N/A`
## 视觉规范
### 字体颜色编码
财务模型用字体颜色表达单元格性质。
| 颜色 | Hex | 含义 |
| --- | --- | --- |
| 蓝色字体 | `#0000FF` | 硬编码输入值、用户可修改假设 |
| 黑色字体 | `#000000` | 本 sheet 公式或普通文本 |
| 绿色字体 | `#008000` | 同工作簿跨 sheet 引用;公式中只要出现跨 sheet 引用就用绿色 |
| 红色字体 | `#FF0000` | 外部文件/外部系统链接,慎用 |
| 灰色斜体 | `#808080` + italic | YoY、Margin、Notes、单位说明、数据来源 |
| 黄色背景 | `#FFFF00` | 待确认或待复核假设 |
辅助行字号与正文一致,只靠灰色和斜体区分层级。除 sheet 顶部大标题外,全表正文、分区标题、副标题、辅助行使用统一字号。
### 数字格式
| 数据类型 | 推荐格式 |
| --- | --- |
| 年份 | `0`,不得显示千分位 |
| 整数 | `#,##0;(#,##0);"-"` |
| 小数 | `#,##0.0;(#,##0.0);"-"``#,##0.00;(#,##0.00);"-"` |
| 货币 | `$#,##0;($#,##0);"-"``$#,##0.00;($#,##0.00);"-"` |
| 百分比 | `0.0%``0.00%`,同一表内保持一致 |
| 估值倍数 | `0.0" x"` |
| 人数/股数 | `#,##0` |
零值显示为 `-`,负数使用括号法 `(123)`。如采用窄货币符号列,符号列单独放 `$` / `¥`,数值列不再带货币符号。
### 单位与来源
单位必须清晰标注:
- 全表单位统一时,在标题下方用灰色斜体副标题标注,如 `($ in Millions, Except Per Share Data)`
- 同一表存在多种单位时,在字段名后直接标注,如 `Revenue ($mm)``Gross Margin (%)``员工人数(万人)``ARPU, 元/月`
历史值、外部数据和硬编码来源必须在表尾或注释中标注:`Source: [来源], [日期], [页/表/字段]`。FY、CY、LTM、NTM、CAGR、YoY、QoQ 等缩写必须使用一致口径。
### 布局与列宽
如果 surface 支持隐藏默认网格线,财务模型应关闭网格线;如果当前 surface 不支持,则不把网格线作为阻塞验收项。
推荐像素宽度:
| 列类型 | 建议宽度 |
| --- | --- |
| 左侧留白列 | 16-24 px |
| 缩进/货币符号窄列 | 28-36 px |
| 标签/科目列 | 220-320 px |
| 年份/期间列 | 64-72 px最多约 80 px且所有年份列等宽 |
分区标题行用于隔离 Key Assumptions、Core Calculation、Terminal Value、Sensitivity 等区块:
- 同层级标题使用相同背景色和相同左右边界。
- 不同层级也保持左右边界一致,只用背景色深浅区分层级。
- 一级建议深蓝底白字加粗;二级中蓝;三级浅蓝。
父子层级通过缩进和加粗表达:父级/合计行加粗,子项正常字重并缩进。
### 边框
边框只表达结构,不做装饰。
| 场景 | 边框 |
| --- | --- |
| 小计行 | 上细线 |
| 关键小计,如 Gross Profit / EBITDA / NOPAT / UFCF | 上细线 + 加粗 |
| 最终合计,如 Net Income / Total Assets / Ending Cash / Implied Share Price | 上细线 + 下双线 + 加粗 |
| 普通数据行 | 无边框 |
| 年份列之间 | 禁止竖线 |
Sensitivity 矩阵可使用浅灰细框作为唯一例外,目的是表达双轴矩阵结构,不得扩展到普通财务报表区域。
### Sensitivity / Scenario
Sensitivity 必须极简、对称、可读。
禁止:
- 条件格式色阶。
- 数据条。
- 斑马纹。
- 图标集。
- 彩虹配色。
推荐:
1. 行轴和列轴以 baseline 为中心等距展开,如 WACC: 8%, 9%, 10%, 11%, 12%。
2. baseline 交点用浅黄背景 `#FFF2CC` + 加粗标注。
3. 所有结果格使用同一 `number_format`
4. 标题下方用灰色斜体说明输出指标。
## 交付前检查
任何财务输出必须检查:
- 已按任务类型选择规则,未过度套用复杂模型结构。
- 年份横向排布,历史/预测后缀清楚,如 A / E / B。
- 假设单独落地,蓝色字体,公式引用假设而非硬编码。
- 横向公式 `$` 锁定已回读验证。
- 蓝/黑/绿/红/灰色编码正确。
- 单位、币种、来源和口径已标注。
- 数字格式统一,负数括号,零值为 `-`,年份无千分位。
- 年份列等宽紧凑,标签列较宽。
- 普通数据行无装饰边框,无竖线。
- 小计/关键小计/最终合计的边框和加粗层级正确。
- 公式结果无错误值。
三表模型额外检查:
- BS 每期 `Total Assets = Total Liabilities + Total Equity`
- CFS Ending Cash 每期等于 BS Cash。
- Retained Earnings 与 Net Income / Dividends 口径一致。
多 sheet 模型额外检查:
- Assumptions 只放输入和说明,不写计算公式。
- Calc 引用 AssumptionsOutput 引用 Calc数据流单向。
- Sensitivity 独立 sheet 或独立区块。
Sensitivity 额外检查:
- 无色阶、无数据条、无斑马纹。
- 仅 baseline 交点高亮。
- 双轴范围围绕 baseline 对称。
## CLI 落地提示
- pandas / DataFrame 结果写入时读取 `lark-sheets-write-cells``+table-put`;目标表还不存在时读取 `lark-sheets-workbook``+workbook-create --sheets`
- 写值、公式、样式时读取 `lark-sheets-write-cells`;多区域或多步骤写入优先用 `lark-sheets-batch-update`
- 调整列宽、行高、合并、排序、复制格式时读取 `lark-sheets-range-operations``lark-sheets-sheet-structure`
- 生成跨 sheet 公式前读取 `lark-sheets-formula-translation`,并按飞书公式语法验证引用、数组和错误值。
- 所有颜色在 CLI 参数中使用带 `#` 的 RGB hex`#0000FF`

View File

@@ -40,6 +40,10 @@
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
**打印场景控制总宽(用户说"适合打印 / A4 / 打印范围"时必做)**:扩单列宽防截断的同时,**所有列宽之和要落在纸张可打印宽度内**——A4 横向约 ≤ 102 个半角字符(约 1000px纵向约 ≤ 70 个字符。超宽时不要无限加宽,改用 `cell_styles.word_wrap="auto-wrap"` + 调高行高,或缩窄非关键列,让整表在一页内(反例:总列宽远超 A4 可打印宽度,且长文本行高不够被截断)。
**只加宽承载新内容的列,不改动原有列的列宽**:列宽自适应**只针对新增 / 真正放不下新内容的列**;原表已有列的列宽**禁止重新计算、禁止缩小**——即便你估算的"理想宽度"与原值不同,只要原内容没被截断就不要动它。无差别地把所有列重设一遍宽度(哪怕只 ±1都属于破坏原文件视觉格式反例填完数据后顺手把原有列的列宽从 16 改成 17与原附件不一致破坏了原视觉格式
**⚠️ 合并单元格安全操作规则**`+cells-{merge|unmerge}` 必读):
1. **先读后写**:操作前必须用 `+sheet-info --include merges``+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。

View File

@@ -13,19 +13,22 @@
预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。一旦设计的逻辑只覆盖 sample 中出现的格式,就属于违规。
⚠️ **大数字15 位以上的身份证 / 参考号 / 流水号)做去重 / 比较时禁止用 `+csv-get` 的显示值**`+csv-get` 返回的是**格式化显示值**15 位以上数字会被显示成 `1.04E+14` 这类科学计数法——多个本不相同的号在显示层全变成同一个 `1.04E+14`,拿去判重会**整列误判为重复**。比较 / 去重 / 匹配大数字时必须改用 `+cells-get`(取原始精确值)或把该列读为文本,禁止用 csv-get 的科学计数显示值(反例:大批长参考号被显示成科学计数后,互不相同的号全变成同一个值,被当成整列重复并错误高亮)。
## 使用场景
读取。从飞书表格中读取单元格数据。本 reference 覆盖 3 个 shortcut按读取目的选择
读取。从飞书表格中读取单元格数据。本 reference 覆盖 4 个 shortcut按读取目的选择
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|---------|----------------|---------|------|
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本( `--rows-json` 改为结构化 rows `{row_number, values:{列字母→值}}`);大表请按 `--range` 行窗口分批读(截断时看 `has_more` |
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more` |
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put` | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[{name,type}]` + `rows`),列类型由 `number_format` 推断、混合列无损降 `string`;类型保真往返 |
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息token 开销较大 |
| 查看某区域的下拉框(数据验证)选项 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围已配置的下拉列表选项 |
**选择原则**
- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文
-结构化、按 `row_number` / 列字母定位的输出 → `+csv-get --rows-json`(默认 CSV 串更省 token超大表批量仍用默认
-按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`)→ `+table-get`
- 需要公式/样式/批注 → `+cells-get`
- 只想知道某区域下拉框有哪些选项 → `+dropdown-get`
@@ -83,6 +86,7 @@
| `+cells-get` | read | 单元格 |
| `+dropdown-get` | read | 对象 |
| `+csv-get` | read | 单元格 |
| `+table-get` | read | 单元格 |
## Flags
@@ -115,7 +119,17 @@ _公共四件套 · 系统:`--dry-run`_
| `--max-chars` | int | optional | 防爆,默认 200000隐藏 flag不在 `--help` 列出,但可正常传入) |
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
| `--rows-json` | bool | optional | 返回结构化 rows`{row_number, values:{列字母→值}}`)而非 CSV 文本,默认 `false` |
### `+table-get`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheet-id` | string | optional | 只读该子表(按 id省略则读所有子表 |
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的当前数据区 |
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
## Examples
@@ -140,17 +154,7 @@ lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
- `current_region` — 自动扩展到非空连续区域的 A1 范围。它是**真实数据边界****优先于 `+workbook-info``row_count`**`row_count` 是网格物理行数,常是 200 / 1000 等默认值、远大于实际数据;按它盲读会拉回大片空行)
- `has_more` — 是否截断;截断后续读用 `--range` 接着读
**加 `--rows-json`:返回结构化 rows而非 CSV 字符串)**
```bash
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:G20" --rows-json
```
`--rows-json` 下的输出契约(替换 `annotated_csv` / `col_indices` / `row_indices`
- `rows` — 数组,每元素 `{row_number, values}``row_number` 是真实表格行号(整数,下游需要行号的操作直接取它);`values` 按**列字母** key`values["D"]`,绝对列字母)。**所有逻辑行都在 `rows` 里**。引号内换行已解析进单元格值,无需自己按 RFC-4180 拆行。
- `data_not_fully_read`**仅当没读全时出现**`{read_through_row, data_extends_through_row, unread_rows, reread_range}`。出现即表示真实数据超出本次读取范围;批量写入前必须按 `reread_range` 重读全区,否则漏行。
- 其余字段(`current_region` / `actual_range` / `has_more`)同上。
> 要按列类型结构化读出(喂 DataFrame、或 round-trip 回 `+table-put`)用 `+table-get`(见下);`+csv-get` 给的是带 `[row=N]` 前缀的纯值快照,下游需要行号/列坐标时直接从前缀与 `col_indices` 取。
### `+cells-get`
@@ -164,6 +168,27 @@ lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --she
> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。
### `+table-get`(飞书 → DataFrame类型保真读出
`+table-put`(写入侧,见 write-cells reference的镜像把表格读回与 `--sheets` 同构的 typed 协议(`sheets[]` + `columns:[{name,type}]` + `rows`),可直接喂回 `+table-put` 或转 DataFrame。列 `type` 从每列 `number_format` 推断(日期格式→`date`、数值→`number``date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`,让 `columns[].type``rows` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 底层复用 `get_cell_ranges` / `get_range_as_csv`。默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。
```bash
# 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame
lark-cli sheets +table-get --url "<表URL>"
# 可选:--sheet-name / --sheet-id 限定只读某一个子表(不给则读全部)
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售"
```
`+table-get` 输出 → DataFrame按读回的 `type` 还原 dtype
```python
sheet = out["data"]["sheets"][0]
df = pd.DataFrame(sheet["rows"], columns=[c["name"] for c in sheet["columns"]])
for c in sheet["columns"]:
if c["type"] == "date": df[c["name"]] = pd.to_datetime(df[c["name"]])
elif c["type"] == "number": df[c["name"]] = pd.to_numeric(df[c["name"]])
```
### Validate / DryRun / Execute 约束
- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。

View File

@@ -0,0 +1,41 @@
# Lark Sheet Recover
## 使用场景
写。整表回退到指定历史 revision方案 B。本 reference 覆盖 1 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 整表回退到历史版本 | `+recover` | 把整个 spreadsheet 回退到 `--to-revision` 指定的版本,丢弃其后所有编辑 |
⚠️ **全文档破坏性回退**`+recover` 丢弃 `--to-revision` 之后的**所有**编辑,包括其他协作者的改动。只在 agent 自己的草稿表、或确认要整表回滚时使用。要精确撤销本链路最近几笔编辑,请用 `+undo`(逐笔、可控、不波及他人)。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+recover` | write | 撤销恢复 |
## Flags
### `+recover`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--to-revision` | int | required | 整表回退到该 revision来自此前写操作返回的 revision |
## Examples
### `+recover`
把整表回退到某个历史版本。`--to-revision` 取此前某次写操作响应里返回的 `revision`
```bash
lark-cli sheets +recover --spreadsheet-token <token> --to-revision 42
```
- 回退会产生一个**新** revision不是删历史而是追加一条回退记录响应里返回这个新 revision。
- 回退不可逆地丢弃 `--to-revision` 之后的内容,执行前先确认要丢弃的范围。
-`+undo` 的区别:`+undo` 精确撤销本 CLI 链路最近 N 笔编辑、不动他人改动;`+recover` 是全文档回到某版本、丢弃所有后续(含他人)。需要细粒度撤销时优先 `+undo`

View File

@@ -6,6 +6,7 @@
## 最高优先级原则
- **用户指令优先**:用户明确提出的格式要求(如"使用红色背景")具有最高权重,即使与通用审美冲突。
- **金融/财务建模规范优先于通用视觉**:当任务是 DCF、三张表、预算、Variance、Sensitivity、估值、FP&A 等场景时,先读取 `lark-sheets-financial-modeling-standards`;其中关于斑马纹、色阶/数据条、年份列宽、竖线、颜色编码、数字格式的规定覆盖本文通用建议。
- **继承原表风格**:编辑前先采样原文件视觉特征(色系、边框、对齐、数字格式),新增内容必须与之对齐。严禁对已有风格的文件强行施加通用标准化格式。
- **扩展而非覆盖**:新增行列或追加数据时,目标是"扩展原模板"——继承邻近区域的表头风格、条纹节奏、边框层级、对齐方式、数字格式和列宽/行高策略。
- **美化只动样式属性,不动数据**:对**已有区域**做美化时,**只能**修改 `font` / `fill` / `border` / `alignment` / `number_format` 这 5 类样式属性。**禁止**改动原始单元格的 `value` / `formula`、合并区域、行列结构、Sheet 名称。如果美化需求需要改变数据布局(例如"汇总行加进表里"),必须把"加汇总行"和"美化"拆成两步,前者属于编辑动作、需另行得到用户授权。
@@ -24,6 +25,8 @@
**差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。
**显式要求边框 / 表头 / 对齐时同样按上面标准落地**(不必等用户说"美化"):① 用户说"给某矩形区域加边框"必须**整个矩形含表头行、数据行、汇总行全部加内外框**,落地后核起 / 末行、末列三边界(反例:要求加边框的区域实际无任何边框);② **新建表头前先确认哪一行才是表头**——别把已有的第一行数据误当表头刷成蓝底白字,真正该加的表头列也要建出来(反例:把第一行数据误设成了表头样式);③ 新增 / 编辑区域的字号必须与原表一致,禁止 13 号与 14 号、10 号与 11 号混杂(反例:新列字号与原表不一致)。
## 通用样式规范
> 以下取值标准都在「最高优先级原则」的**继承原表风格 / 扩展而非覆盖**前提下生效:凡涉及"沿用原表"的条目,遵循该原则即可,本节不再逐条复述。
@@ -201,4 +204,3 @@ Step 3 — 微调收尾:`+batch-update` + `+rows-resize / +cols-resize` / `+ce
- 合并区域样式只写左上角,不要对合并内的其他单元格重复写入样式。
> 合并单元格完整的安全操作规则(含数据保护、样式占位等 5 条)见 `lark-sheets-range-operations` 的 `+cells-{merge|unmerge}` 章节。

View File

@@ -10,7 +10,7 @@
## 使用场景
读写。管理工作簿结构。本 reference 覆盖 11 个 shortcut
读写。管理工作簿结构。本 reference 覆盖 14 个 shortcut
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
@@ -45,6 +45,7 @@
| `+sheet-show-gridline` | write | 工作簿 |
| `+workbook-create` | write | 工作簿 |
| `+workbook-export` | read | 工作簿 |
| `+workbook-import` | write | 工作簿 |
## Flags
@@ -61,7 +62,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--title` | string | required | 新工作表名称 |
| `--index` | int | optional | 插入位置;省略时附加到末尾 |
| `--index` | int | optional | 插入位置0-based;省略时附加到末尾 |
| `--row-count` | int | optional | 初始行数(默认 200上限 50000 |
| `--col-count` | int | optional | 初始列数(默认 20上限 200 |
@@ -137,8 +138,9 @@ _系统`--dry-run`_
| --- | --- | --- | --- |
| `--title` | string | required | 新 spreadsheet 标题 |
| `--folder-token` | string | optional | 目标文件夹 token省略时放在云空间根目录 |
| `--headers` | string + File + Stdin简单 JSON | optional | 表头行 JSON 数组`["列A","列B"]` |
| `--values` | string + File + Stdin简单 JSON | optional | 初始数据 JSON 二维数组:`[["alice",95]]` |
| `--values` | string + File + Stdin简单 JSON | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行)`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
| `--sheets` | string + File + Stdin复合 JSON | optional | 建表后写入的 typed 表格协议 JSON同 +table-put顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}type 为 string/number/date/bool。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--styles` | string + File + Stdin复合 JSON | optional | 建表时同时写入的视觉处理操作 JSON顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style含 number_format / 颜色 / 对齐 / border_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。 |
### `+workbook-export`
@@ -150,6 +152,41 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) |
| `--output-path` | string | optional | 本地保存路径;省略时只触发导出不下载 |
### `+workbook-import`
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--file` | string | required | 本地文件路径(.xlsx / .xls / .csv |
| `--folder-token` | string | optional | 目标文件夹 token省略则导入到云空间根目录 |
| `--name` | string | optional | 导入后表格名称;省略则用本地文件名(去掉扩展名) |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+workbook-create` `--sheets`
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
**数组项**(类型 object
- `name` (string) — 目标子表名
- `start_cell` (string?) — 写入起点单元格A1 记法,如 "B2"),默认 "A1"
- `mode` (enum?) — overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头 [overwrite / append]
- `header` (boolean?) — 是否写一行列名表头
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success
- `columns` (array<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
### `+workbook-create` `--styles`
**数组项**(类型 object
- `cell_merges` (array<object>?) — 单元格合并操作数组range 使用 A1 单元格范围merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border_styles?: object, font_color?: string, font_line?: enum, font_size?: number, …共 12 项 }
- `col_sizes` (array<object>?) — 列宽操作数组range 使用列范围如 A:Ctype 为 pixel/standardpixel 需要 size each: { range: string, size?: number, type: enum }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组range 使用行范围如 1:3type 为 pixel/standard/autopixel 需要 size each: { range: string, size?: number, type: enum }
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id``--sheet-name`
@@ -158,6 +195,111 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`/ `row_count` / `column_count` / `index` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
### `+workbook-create`
新建电子表格可选预填数据。两种数据入口untyped `--values` / typed `--sheets`**互斥**,按需二选一——两者都走同一条分批 `set_cell_range` 写入:
```bash
# 1) untyped--values一个二维数组表头并入第一行值原样写、类型由飞书自动识别
# 日期会落成文本,配 --styles 控制格式)
lark-cli sheets +workbook-create --title "销售" \
--values '[["门店","销售额"],["北京",259874]]'
# 2) typed--sheets一步建表 + 类型保真。date 列落成真日期(可排序/透视)、
# number 不丢精度、string 列保前导零(如订单号 00123多子表一次建。
lark-cli sheets +workbook-create --title "交易" --sheets '{
"sheets":[
{"name":"明细","columns":[
{"name":"日期","type":"date"},
{"name":"金额","type":"number","format":"#,##0.00"},
{"name":"单号","type":"string"}
],"rows":[["2024-01-15",1234.5,"00123"]]}
]}'
```
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`)。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip
`--styles` 可在建表写入时同时写视觉处理。它和 `--sheets` 一样只有一种外层写法:顶层对象里放 `styles` 数组;数组每项对应一个子表,含 `name`,并按能力拆成四类可选数组:
- `cell_styles`:像 `+cells-set-style`,用 A1 单元格 `range` 加扁平样式字段(`font_weight` / `background_color` / `number_format` 等)和可选 `border_styles`;这些样式会合并进同一次内容 `set_cell_range`
- `cell_merges`:用 A1 单元格 `range` 设置合并,`merge_type` 默认为 `all`,可选 `rows` / `columns`
- `row_sizes`:用行范围(如 `1:3`)设置行高,`type``pixel` / `standard` / `auto``pixel` 需要 `size`
- `col_sizes`:用列范围(如 `A:C`)设置列宽,`type``pixel` / `standard``pixel` 需要 `size`
同一单元格命中多个 `cell_styles` 项时,后面的操作继续合并覆盖已传字段。`cell_merges` / `row_sizes` / `col_sizes` 在内容写入后顺序执行。
```bash
# 3) untyped仍用 {"styles":[...]}只有一个子表样式项name 忽略range 覆盖 --values 初始区域
lark-cli sheets +workbook-create --title "销售" \
--values '[["门店","销售额"],["北京",259874],["上海",198320]]' \
--styles '{
"styles":[
{"name":"Sheet1","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5"},
{"range":"B2:B3","number_format":"#,##0"}
]}
]
}'
# 4) typed 单子表:--styles.styles[0].name 必须对应 --sheets.sheets[0].name
lark-cli sheets +workbook-create --title "交易" --sheets '{
"sheets":[
{"name":"明细","columns":[
{"name":"日期","type":"date"},
{"name":"金额","type":"number","format":"#,##0.00"}
],"rows":[["2024-01-15",1234.5]]}
]}' --styles '{
"styles":[
{"name":"明细",
"cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5",
"border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},
{"range":"A2:A2","number_format":"yyyy-mm-dd"},
{"range":"B2:B2","number_format":"#,##0.00","font_color":"#0f7b0f"}
],
"cell_merges":[{"range":"A1:B1"}],
"col_sizes":[{"range":"A:B","type":"pixel","size":120}],
"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}
]
}'
# 5) typed 多子表styles 数组和 sheets 数组长度、顺序、name 都必须一致
lark-cli sheets +workbook-create --title "经营看板" --sheets '{
"sheets":[
{"name":"收入","columns":[{"name":"月份","type":"string"},{"name":"收入","type":"number","format":"#,##0"}],"rows":[["2026-05",1200000]]},
{"name":"成本","columns":[{"name":"月份","type":"string"},{"name":"成本","type":"number","format":"#,##0"}],"rows":[["2026-05",730000]]}
]}' --styles '{
"styles":[
{"name":"收入","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f0f7ff"},
{"range":"B2:B2","font_color":"#0f7b0f"}
]},
{"name":"成本","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#fff7ed"},
{"range":"B2:B2","font_color":"#b42318"}
]}
]
}'
```
> ⚠️ **`+workbook-create` 是把内存里的数据写成新表;要把已有的本地 Excel/CSV 文件原样导入成新表,用 `+workbook-import`**(见下),不要先在本地读出文件再 `+workbook-create` 重灌。
### `+workbook-import`
把已有的本地 `.xlsx` / `.xls` / `.csv` 文件导入为一个**新的**飞书电子表格(异步任务 + 内置轮询),与 `+workbook-export`(导出)对称。底层复用 drive 的导入实现,固定导入为电子表格类型。
```bash
# 导入到云空间根目录;表格名默认取本地文件名(去掉扩展名)
lark-cli sheets +workbook-import --file ./data.xlsx
# 指定目标文件夹与导入后表格名
lark-cli sheets +workbook-import --file ./report.csv --folder-token <FOLDER_TOKEN> --name "月度报表"
```
- **不接受任何 spreadsheet / sheet 定位 flag**(它是新建,不操作已有表):只有 `--file`(必填)/ `--folder-token` / `--name`
- 仅导入为电子表格sheet。若要把本地表格导入成多维表格bitable改用 `lark-cli drive +import --type bitable`
- 返回 `token` / `url`(导入完成的新表格)/ `ticket` / `ready` / `job_status`;未在内置轮询窗口内完成时返回 `timed_out=true` 与续查命令 `next_command`
### `+sheet-create`
示例:
@@ -214,6 +356,6 @@ lark-cli sheets +sheet-hide-gridline --url "..." --sheet-id "$SID"
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200`+sheet-delete` 必须 `--yes``--dry-run`
- `Validate`XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200`+sheet-delete` 必须 `--yes``--dry-run``+workbook-create``--sheets``--values` **互斥**,给了 `--sheets` 则按 typed 协议校验 payload其余约束同 `+table-put`
- `DryRun``+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata"`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符,不实际解析为 sheet-id。
- `Execute`:写操作不自动回读;如需确认目标 sheet 的新状态,自行调用 `+workbook-info`

View File

@@ -5,6 +5,7 @@
1. **明确写入边界**:写入前必须能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,禁止扩张到原数据列以外或新建 Sheet。
2. **完整性断言**:批量写入前先把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读断言 `actual == expected`。少于预期就继续写,禁止交付半成品。
3. **回读抽样校验**:写完关键值 / 公式后,用 `+csv-get``+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时高频致命)**:派生结果一律写到**真实末列 +1 的全新空列**或新建子表,**禁止复用任何已有原数据列**——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条铁律:① 不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前必须用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
## 新增列 / 新增行的样式继承(防止视觉风格不一致)
@@ -44,7 +45,30 @@
## 使用场景
写入。为一块单元格区域设置值、公式、批注/备注和/或格式。也支持通过 `rich_text``type: "embed-image"` 在单元格内嵌入图片(单元格图片)。关键:数组维度必须严格匹配——`cells` 二维数组必须与 `range` 的行列维度完全一致range 是闭区间,否则会触发 `InvalidCellRangeError`。计算示例:区域 `A1:D3` = 3 行 × 4 列 = `[[r1c1,r1c2,r1c3,r1c4],[r2c1,r2c2,r2c3,r2c4],[r3c1,r3c2,r3c3,r3c4]]`;区域 `A41:N48` = 8 行 × 14 列 = 8 个数组且每个数组 14 个单元;单个单元格 `A1` = `[[cell]]`;单列区域 `B5:B7` = `[[cell1],[cell2],[cell3]]`。空单元请使用 `{}`。**如果填写的区域存在大量重复内容,务必优先使用 `--copy-to-range` 字段复制,可大幅减少 `cells` 长度。**
写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut按数据来源 + 内容形态选:
| 场景 | 用这个 shortcut | 原因 |
|------|----------------|------|
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书要类型保真来源不限DataFrame、Counter、dict、list 都算) | `+table-put` | 列显式声明 `type`date 落真日期、**金额 / 百分比 / 计数等数值列保精度且带 `number_format`(可排序 / 求和 / 入图表)**、string 保前导零,多 sheet 一次写。**只要列有数值语义就走这里**,不要在本地把数字拼成带 `$` / `%` 的字符串再走 `+csv-put` |
| 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut公式 `+csv-put` 也能写) |
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag不触发不必要的值写入 |
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
**优先级**:常规批量写入(纯值或公式)优先 `+csv-put`(最短入参,直接传 CSV 文本);含样式/批注/图片才用 `+cells-set`。⚠️ 这里"纯值"特指**已是文本、无需保留数值语义**的内容;只要列里是金额 / 百分比 / 日期 / 计数等有数值语义的数据,应优先 `+table-put`(声明 `number` / `date` 类型 + `number_format`),而不是 `+csv-put`
⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果;含逗号的公式按 RFC 4180 用双引号包裹整列,如 `"=SUM(B2,C2)"`)。但**不会**携带样式/批注/图片,也无法把 `=` 开头的内容当字面量文本写入;需要样式/批注/图片用 `+cells-set`(或"写值 + 补样式"两步法)。
⚠️ **别把本该是数值的列格式化成字符串用 `+csv-put` 写入**(高频反模式):金额 / 百分比 / 市值 / 计数等列,若在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格会变成**文本**——丢失排序 / 求和 / 图表 / 透视能力,且与 `number` 列混排时无法参与计算。正解是 `+table-put` 声明该列 `type:"number"`(百分比存小数,如 `0.305`+ `format`(如 `"$#,##0.00"` / `"0.0%"` / `"#,##0"`**显示效果完全相同、数值无损**。判断信号:**当你准备把一个数字 format 成字符串再写时,几乎总该用 `+table-put` 而非 `+csv-put`**。
⚠️ 大数据回写走"`+csv-get``--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
## `+cells-set` 写入要点(高频模式 / 公式 / 样式)
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的高频模式与铁律;选哪个 shortcut 见上方「使用场景」。
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text``type: "embed-image"` 嵌入单元格图片。**关键:`cells` 二维数组的行列维度必须与 `range`(闭区间)严格一致,否则触发 `InvalidCellRangeError`**——维度计算示例见文末 `## Schemas``--cells`
> **单元格图片 vs 浮动图片**
> - **单元格图片**(本工具):图片嵌入在单元格内部,属于单元格内容,随单元格移动。通过 `rich_text` 中 `type: "embed-image"` 写入。
@@ -96,6 +120,7 @@ Step 2: `+cells-set` — range="A2", cells 含 value + cell_styles + border_styl
5. **循环引用预检(高频致命错误)**写聚合公式SUM / AVERAGE / COUNT 等)前必须明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`/ 缩小范围避开自己
6. **REGEX 模式覆盖率验证**:公式里的 `REGEXEXTRACT` / `REGEXMATCH` / `REGEXREPLACE` 等正则模式落地前必须用本地脚本在源列上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时必须扩展 pattern 或加多分支IFS / 多个 IFERROR 串联)兜底,**禁止**只覆盖样本前 N 行就交付(典型反例:用 `REGEXEXTRACT(D5,"长(\d+)")` 只匹配带"长"前缀的尺寸文本,对"宽×高"、"×"、"*"等其它分隔符直接漏匹配)
7. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"就必须写 `SUM(F2:L2)``F2+G2+H2+I2+J2+K2+L2`**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是违规
8. **量纲 / 单位换算 / 数量乘项预检(高频致命错误,公式不报错但结果整体偏倍数)**:从文本提取数字做计算前,先核对**单位是否统一、是否漏乘数量、口径是否一致**——这类错误公式能跑通、无 `#` 报错,回读也看不出(值"像对的")。必须用本地脚本对 35 个代表行**离线手算一遍预期值**,与公式结果逐格比对量级:① 单位不一致先统一再算(典型反例:尺寸 `320CM*337CM` 直接取数相乘除以 1e6 得 0.11,正确是 CM→MM 换算后得 10.78**差 100 倍**);② 按"单件×数量"的量必须乘数量列(典型反例:侧面板面积漏乘 F 列数量F=2 的行只算了一半);③ 标准值口径对齐(典型反例:营养成分 mg/kg 与 g/100g 口径混用,整列放大 100 倍)。**口径 / 单位 / 数量任一项错,整列计算结果就是错的;这类错误公式不报错、回读也不易看出,必须靠离线手算对照。**
⚠️ **收到 `formula_errors` 反馈后不要只打补丁(高频致命错误)**`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错如 `SPLIT(x)[1]` 的下标取值飞书不支持SPLIT 本身支持,取第 N 项用 `INDEX(SPLIT(...),N)``non_formula``=` 开头但解析不通过)。此时**禁止只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),必须:
@@ -208,24 +233,6 @@ lark-cli sheets +dropdown-set \
`+dropdown-update`(多 range 批量更新)的所有 flag 语义与 `+dropdown-set` 完全一致;只是目标 `--ranges` 由单值变成 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。
## 工具选择
本 skill 提供以下 CLI shortcut按数据来源 + 内容形态选:
| 场景 | 用这个 shortcut | 原因 |
|------|----------------|------|
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
| 写入含公式、样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整字段的 shortcut |
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag不触发不必要的值写入 |
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
| 大量纯值 + 需要表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
**优先级**:常规纯值写入优先 `+csv-put`(最短入参,直接传 CSV 文本);含公式/样式/批注/图片才用 `+cells-set`
⚠️ `+csv-put` 只写纯值,**不会**携带公式/样式/批注/图片;公式字符串以 `=` 开头会被当作字面量文本落地。如果数据里需要公式或样式,**必须**用 `+cells-set`(或"写值 + 补样式"两步法)。
⚠️ 大数据回写走"`+csv-get``--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
## Shortcuts
| Shortcut | Risk | 分组 |
@@ -235,6 +242,7 @@ lark-cli sheets +dropdown-set \
| `+cells-set-image` | write | 单元格 |
| `+dropdown-set` | write | 对象 |
| `+csv-put` | write | 单元格 |
| `+table-put` | write | 单元格 |
## Flags
@@ -299,10 +307,18 @@ _公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--start-cell` | string | required | 目标区域起点 A1`A1``B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet必须是单个单元格不接受范围写法终点按 CSV 实际行列数自动推断 |
| `--csv` | string + File + Stdin非 JSON 文本) | required | RFC 4180 CSV 文本;只写纯值,不带公式/样式/批注 |
| `--csv` | string + File + Stdin非 JSON 文本) | required | RFC 4180 CSV 文本;可写值或公式(以 = 开头的单元格按公式计算);不带样式 / 批注 / 图片,需要这些用 +cells-set。 |
| `--allow-overwrite` | bool | optional | 允许覆盖(默认 true设为 false 时若目标非空报错 |
| `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17时自动取其左上角单元格隐藏 flag不在 `--help` 列出,但可正常传入) |
### `+table-put`
_公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheets` | string + File + Stdin复合 JSON | required | typed 表格协议 JSON顶层 sheets 数组,每项 {name, start_cell?, mode?, header?, allow_overwrite?, columns:[{name,type,format?}], rows:[[...]]}type 为 string/number/date/bool |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
@@ -338,6 +354,19 @@ _列表选项_
**数组项**(类型 string
- 标量string
### `+table-put` `--sheets`
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
**数组项**(类型 object
- `name` (string) — 目标子表名
- `start_cell` (string?) — 写入起点单元格A1 记法,如 "B2"),默认 "A1"
- `mode` (enum?) — overwrite默认从 start_cell 起写「表头 + 数据」块append把数据追加到子表已有数据下方默认不重复表头 [overwrite / append]
- `header` (boolean?) — 是否写一行列名表头
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success
- `columns` (array<object>) — 列定义,顺序与 rows 中每行的取值一一对应 each: { name: string, type: enum, format?: string }
- `rows` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 columns 数
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`XOR
@@ -413,15 +442,16 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
--start-cell "A1" --csv @data.csv
```
> `+csv-put` 比 `+cells-set` 短得多——只想批量灌值时优先用它。需要式/样式才换 `+cells-set`。
> `+csv-put` 比 `+cells-set` 短得多——批量灌值或公式时优先用它。需要式/批注/图片才换 `+cells-set`。
>
> ⚠️ `=` 开头的字符串会被当字面量写入(**不会变公式**
> `=` 开头的单元格会被当作公式计算(不是字面量文本
>
> ```bash
> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \
> --start-cell "A1" \
> --csv $'name,score\nalice,=SUM(B2:B10)'
> # ↑ A2 实际写入字符串 "=SUM(B2:B10)"**不是公式**。需要写公式请用 +cells-set
> # ↑ B2 写入公式 =SUM(B2:B10),读回 formula 保留、value 为计算结果
> # 反过来:无法用 +csv-put 写「= 开头的字面量文本」(会被当公式);样式/批注/图片仍用 +cells-set。
> ```
> **定位 + 写入边界(关键,避免误覆盖)**
@@ -430,6 +460,44 @@ lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。
> - 要保护非空 cell`--allow-overwrite=false`(落区内出现非空 cell 即报错)。
### `+table-put`DataFrame → 飞书,类型保真写入)
把带类型的结构化数据DataFrame类型保真地写入**已有**表,底层复用 `set_cell_range`(同 `+cells-set`。typed 协议:顶层 `sheets[]`,每 sheet 带 `columns:[{name,type,format?}]` + `rows`(二维数组,`null`=空单元格),列 `type``string` / `number` / `date` / `bool`**显式声明**,不让 CLI 猜,避免邮编 / 订单号等"像数字的文本"被误判)。`date` 列的 ISO `yyyy-mm-dd` 字符串会转成 Excel 序列号 + 日期 `number_format`(真日期,可排序 / 透视 / 筛选)。
只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同 typed 协议、一步建表 + 类型保真写入,无需先建空表再回来,详见 workbook reference。读回用镜像命令 `+table-get`(见 read-data reference输出与 `--sheets` 同构、可 round-trip。
```bash
# sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet
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
```
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`
**前提:此 helper 需 pandas。** 注意一台机器常装多个 Python`python3` 未必指向装了 pandas 的那个——撞 `ModuleNotFoundError` 就换个解释器(如 `/usr/bin/python3`)再试。**不想依赖 pandas 也行**typed 协议就是纯 JSON直接手写 `columns` + `rows`(不经 helper一样喂给 `--sheets -`。DataFrame → 协议 的薄 helper一次清洗`NaN→null``Timestamp→ISO``numpy 标量→原生`
```python
import pandas as pd, numpy as np
def df_to_sheet(df, name, formats=None):
formats = formats or {}
def coltype(s):
if pd.api.types.is_datetime64_any_dtype(s): return "date"
if pd.api.types.is_bool_dtype(s): return "bool"
if pd.api.types.is_numeric_dtype(s): return "number"
return "string"
def cell(v):
if pd.isna(v): return None
if isinstance(v, pd.Timestamp): return v.date().isoformat()
if isinstance(v, np.generic): return v.item()
return v
columns = [{"name": str(c), "type": coltype(df[c]),
**({"format": formats[c]} if c in formats else {})} for c in df.columns]
rows = [[cell(v) for v in r] for r in df.itertuples(index=False, name=None)]
return {"name": name, "columns": columns, "rows": rows}
# payload = {"sheets": [df_to_sheet(df, "销售", {"日期": "yyyy-mm-dd"})]}json.dump 经 stdin 喂给 +table-put --sheets -
```
### Validate / DryRun / Execute 约束
- `Validate`XOR 公共四件套;`+cells-set``--cells` 必须能解析为 JSON 二维矩阵且行列数与 `--range` 完全一致;`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles``+cells-set-image``--range` 必须是单 cell起止 cell 相同);`+csv-put``--csv` 必须能按 RFC 4180 解析;防爆参数上限校验。

View File

@@ -36,7 +36,7 @@ metadata:
| "会议现在还开着,谁刚加入了"、"会议里谁在发言"、"有人共享屏幕吗"**进行中会议**,且**机器人已入会** | **本 skill** `+meeting-events` |
| "退出会议"、"让机器人离开" | **本 skill** `+meeting-leave` |
| "昨天那场会有谁参加过"、"搜昨天的会"、"查纪要/逐字稿/录制" | [`lark-vc`](../lark-vc/SKILL.md) |
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill入会 → 读事件 离会)→ [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md)拉纪要→ [`lark-im`](../lark-im/SKILL.md)发群 |
| "帮我参会,结束后把纪要发到群" 等跨阶段场景 | 按序编排:本 skill入会 → 读事件会议结束后用 [`lark-vc`](../lark-vc/SKILL.md) / [`lark-minutes`](../lark-minutes/SKILL.md) 拉纪要 → [`lark-im`](../lark-im/SKILL.md) 发群 |
## 核心场景
@@ -66,12 +66,12 @@ metadata:
### 3. 离开会议(写操作)
1. 任务完成、或用户要求结束时,用 `+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`
1. 只有用户明确要求机器人退出 / 离开 / 结束参会时,`+meeting-leave --meeting-id <从 +meeting-join 拿到的 meeting.id>`;不要把任务完成当作离会指令
2. `--meeting-id` **必须**是 `+meeting-join` 返回的长数字 `meeting.id`**不接受 9 位会议号**
3. 离会**立即生效**,机器人从会议的参会人列表中消失,对其他参会人可见;若需要重新入会,再跑一次 `+meeting-join` 即可(非真正"不可逆")。
4. 仅支持 `user` 身份。
### 4. Agent 参会最小闭环示范
### 4. Agent 参会示范
```bash
# 1. 入会,捕获 meeting.id
@@ -83,13 +83,12 @@ MID=$(echo "$JOIN" | jq -r '.data.meeting.id')
# 典型间隔 10-30 秒
lark-cli vc +meeting-events --meeting-id "$MID" --page-all --format pretty
# 3. 任务完成或用户要求结束时离会
lark-cli vc +meeting-leave --meeting-id "$MID"
# 4. 会后可选:取纪要 / 逐字稿(跨到 lark-vc
# 3. 会后可选:取纪要 / 逐字稿(跨到 lark-vc
lark-cli vc +notes --meeting-ids "$MID"
```
如果用户随后明确要求退出 / 离开 / 结束参会,再单独调用 `lark-cli vc +meeting-leave --meeting-id "$MID"`
## Shortcuts
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。

View File

@@ -238,7 +238,7 @@ lark-cli vc +meeting-events \
## 参考
- [lark-vc-agent-meeting-join](lark-vc-agent-meeting-join.md) — 先真实入会
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 完成任务后离会
- [lark-vc-agent-meeting-leave](lark-vc-agent-meeting-leave.md) — 用户明确要求时离会
- [lark-vc-search](../../lark-vc/references/lark-vc-search.md) — 搜索历史会议(获取 meeting_id
- [lark-vc-recording](../../lark-vc/references/lark-vc-recording.md) — 查询 minute_token
- [lark-vc-notes](../../lark-vc/references/lark-vc-notes.md) — 获取会议纪要

View File

@@ -84,14 +84,14 @@ lark-cli vc +meeting-join --meeting-number 123456789 --dry-run
## Agent 组合场景
### 场景 1加入会议 → 离开会议(最小闭环)
### 场景 1加入会议 → 监听会中事件
```bash
# 第 1 步:加入会议,记录返回的 meeting.id
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:完成任务后,使用上一步返回的 meeting.id 离开会议
lark-cli vc +meeting-leave --meeting-id <meeting.id>
# 第 2 步:使用返回的 meeting.id 查询会中事件
lark-cli vc +meeting-events --meeting-id <meeting.id> --page-all --format pretty
```
### 场景 2加入会议 → 会后拉取纪要 / 录制
@@ -100,13 +100,10 @@ lark-cli vc +meeting-leave --meeting-id <meeting.id>
# 第 1 步:加入并参会
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:
lark-cli vc +meeting-leave --meeting-id <meeting.id>
# 第 3 步:会议结束后,查询录制(拿到 minute_token
# 第 2 步:会议结束后,查询录制(拿到 minute_token
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 4 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
# 第 3 步:查询会议纪要(总结 / 待办 / 章节 / 逐字稿)
lark-cli vc +notes --meeting-ids <meeting.id>
```
@@ -123,7 +120,7 @@ lark-cli vc +notes --meeting-ids <meeting.id>
## 提示
- 仅在 Agent 需要**真实加入**会议(例如参会机器人、会中助手)时使用;只拉取会议数据不需要入会。
- 入会会让机器人立即出现在参会列表;若要回退,直接 `+meeting-leave` 即可。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
- 入会会让机器人立即出现在参会列表;若用户要求退出 / 离开 / 结束参会,直接 `+meeting-leave` 即可。参数格式不确定时可选 `--dry-run` 预览,但不是必经步骤。
- 执行成功后,立即记录返回的 `meeting.id`,用于后续 `+meeting-leave` / `+meeting-events`
## 参考

View File

@@ -44,7 +44,7 @@ lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
### 4. 离会立即生效,对其他参会人可见
机器人会立刻从参会列表消失;若会议启用了录制/纪要bot 的参会时段到此截止。确认任务完成再调用;如需要重新入会,再跑 `+meeting-join` 即可(非真正"不可逆")。
机器人会立刻从参会列表消失;若会议启用了录制/纪要bot 的参会时段到此截止。只有在用户明确要求退出 / 离开 / 结束参会时才调用;如需要重新入会,再跑 `+meeting-join` 即可(非真正"不可逆")。
## 输出结果
@@ -59,29 +59,28 @@ lark-cli vc +meeting-leave --meeting-id 69xxxxxxxxxxxxx28 --dry-run
## Agent 组合场景
### 场景 1加入 → 完成任务 → 离开(最小闭环)
### 场景 1加入 → 用户明确要求时离开
```bash
# 第 1 步:加入会议,记录 meeting.id
lark-cli vc +meeting-join --meeting-number 123456789
# 第 2 步:在会中完成任务(如监听发言、记录信息等)
# 第 2 步:在会中处理用户请求(如监听发言、记录信息等)
# ...
# 第 3 步:使用上一步记录的 meeting.id 离会
# 第 3 步:仅在用户明确要求退出 / 离开 / 结束参会时,使用上一步记录的 meeting.id 离会
lark-cli vc +meeting-leave --meeting-id <meeting.id>
```
### 场景 2会后补拉产物
### 场景 2会后补拉产物(不需要离会)
如果用户只是要求会议结束后拉录制、纪要或逐字稿,不要先调用 `+meeting-leave`;直接跨到 `lark-vc` 查询会后产物。
```bash
# 第 1 步:离会后会议仍在进行或已结束
lark-cli vc +meeting-leave --meeting-id <meeting.id>
# 第 2 步:会议结束后查询录制
# 第 1 步:会议结束后查询录制
lark-cli vc +recording --meeting-ids <meeting.id>
# 第 3 步:查询会议纪要
# 第 2 步:查询会议纪要
lark-cli vc +notes --meeting-ids <meeting.id>
```
@@ -95,9 +94,9 @@ lark-cli vc +notes --meeting-ids <meeting.id>
## 提示
- 离会会让机器人从参会列表消失,对其他参会人可见若需要重新入会直接再 `+meeting-join`,不是真正的"不可逆"。参数格式不确定时可选 `--dry-run` 预览。
- `+meeting-join` 成对使用:能 join 的身份才能 leave。
- `meeting_id` 必须来自 `+meeting-join` 返回值,不要用 9 位会议号。
- 只有用户明确要求退出 / 离开 / 结束参会时才调用;离会会让机器人从参会列表消失,对其他参会人可见若需要重新入会直接再 `+meeting-join`,不是真正的"不可逆"。参数格式不确定时可选 `--dry-run` 预览。
- `+meeting-leave` 依赖 `+meeting-join` 返回的 `meeting.id`,但不是每次 join 后都必须调用 leave。
- `meeting_id` 优先使用 `+meeting-join` 返回`meeting.id`;如果来自 `+search`,也必须先确认当前身份就在该会议中。不要用 9 位会议号。
## 参考

View File

@@ -143,14 +143,14 @@ func TestSheets_CRUDE2EWorkflow(t *testing.T) {
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
})
t.Run("export spreadsheet with +export as bot", func(t *testing.T) {
t.Run("export spreadsheet with +workbook-export as bot", func(t *testing.T) {
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
outputDir := t.TempDir()
outputPath := filepath.Join(outputDir, "export.xlsx")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"sheets", "+export",
"sheets", "+workbook-export",
"--spreadsheet-token", spreadsheetToken,
"--file-extension", "xlsx",
"--output-path", "./export.xlsx",