From b42db647ff527cfc38df51ba7027ced97709631a Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 8 Jun 2026 15:05:26 +0800 Subject: [PATCH 1/5] feat(sheets): add +undo shortcut for AI-tool edits Add a token-scoped +undo shortcut that reverses recent sheet edits made through the sheet-ai write tools, by invoking the undo_last write tool. - +undo [--steps N | --op NAME]: undo the last N steps, or a named op - thread a session-stable transaction id (LARK_CLI_SHEET_TRANSACTION_ID) into the tool request's extra.transaction_id, so a group of edits and a later +undo share one server-side undo stack; omitted when unset to preserve per-request behavior - flag-defs + generated defs for the new shortcut --- shortcuts/sheets/data/flag-defs.json | 41 +++++++++ shortcuts/sheets/flag_defs_gen.go | 10 +++ shortcuts/sheets/lark_sheet_undo.go | 108 +++++++++++++++++++++++ shortcuts/sheets/lark_sheet_undo_test.go | 96 ++++++++++++++++++++ shortcuts/sheets/sheet_ai_api.go | 31 ++++++- shortcuts/sheets/sheet_ai_api_test.go | 43 +++++++++ shortcuts/sheets/shortcuts.go | 3 + 7 files changed, 330 insertions(+), 2 deletions(-) create mode 100644 shortcuts/sheets/lark_sheet_undo.go create mode 100644 shortcuts/sheets/lark_sheet_undo_test.go create mode 100644 shortcuts/sheets/sheet_ai_api_test.go diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 488e07e2a..3913f98fc 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -1,4 +1,45 @@ { + "+undo": { + "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": "steps", + "kind": "own", + "type": "int", + "required": "optional", + "desc": "Undo the most recent N edits made through this CLI link (default 1); XOR with `--op`", + "default": "1" + }, + { + "name": "op", + "kind": "own", + "type": "string", + "required": "optional", + "desc": "Undo a specific operation by its operation_id (from a prior write's undo handle); XOR with `--steps`" + }, + { + "name": "dry-run", + "kind": "system", + "type": "bool", + "required": "optional", + "desc": "" + } + ] + }, "+workbook-info": { "risk": "read", "flags": [ diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index 9709f1ac6..e213b6ac6 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -936,6 +936,16 @@ var flagDefs = map[string]commandDef{ {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, }, }, + "+undo": { + 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: "steps", Kind: "own", Type: "int", Required: "optional", Desc: "Undo the most recent N edits made through this CLI link (default 1); XOR with `--op`", Default: "1"}, + {Name: "op", Kind: "own", Type: "string", Required: "optional", Desc: "Undo a specific operation by its operation_id (from a prior write's undo handle); XOR with `--steps`"}, + {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, + }, + }, "+workbook-create": { Risk: "write", Flags: []flagDef{ diff --git a/shortcuts/sheets/lark_sheet_undo.go b/shortcuts/sheets/lark_sheet_undo.go new file mode 100644 index 000000000..5deb35709 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_undo.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// ─── lark_sheet_undo ────────────────────────────────────────────────── +// +// Wraps: +// - undo_last (write) — powers +undo +// +// Reverses the most recent edits this CLI link made to a spreadsheet. The +// backend already records an inverse changeset for every write (see the +// undo design doc, "方案 A"); +undo asks the backend executor to read that +// changeset back and re-apply it in reverse order on the node Workbook, then +// push the result upstream as a collaboration change. The CLI only triggers +// the tool — the read-back endpoint is space-internal and not reachable +// through the /open-apis gateway, so all the heavy lifting stays server-side. +// +// +undo carries no sheet selector: undo is scoped to the spreadsheet + this +// link's edit history, not a single sub-sheet. The two selection modes are +// XOR: +// - --steps N : undo the last N edits (default 1) +// - --op : undo one specific operation_id surfaced by a prior write's +// undo handle + +// Undo wraps undo_last: reverse the most recent edits made through this CLI +// link, either the last N steps (--steps) or one specific operation (--op). +var Undo = common.Shortcut{ + Service: "sheets", + Command: "+undo", + Description: "Undo the most recent edits this CLI link made to a spreadsheet (last N steps, or a specific operation).", + Risk: "write", + Scopes: []string{"sheets:spreadsheet:write_only"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: flagsFor("+undo"), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + _, err = undoInput(runtime, token) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + token, _ := resolveSpreadsheetToken(runtime) + input, _ := undoInput(runtime, token) + return invokeToolDryRun(token, ToolKindWrite, "undo_last", input) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + token, err := resolveSpreadsheetToken(runtime) + if err != nil { + return err + } + input, err := undoInput(runtime, token) + if err != nil { + return err + } + out, err := callTool(ctx, runtime, token, ToolKindWrite, "undo_last", input) + if err != nil { + return err + } + runtime.Out(out, nil) + return nil + }, + Tips: []string{ + "Undo is scoped to edits made through this CLI link — it never touches changes other collaborators (or the web UI) made to the same range.", + "Use --dry-run to preview which steps would be undone before running it.", + }, +} + +// undoInput builds the undo_last tool body and enforces the --steps / --op +// XOR. --steps carries a default of 1, so the mutual-exclusion check keys off +// Changed("steps") (whether the user actually passed it) rather than its +// value. Network-free; shared by Validate, DryRun, and Execute. +func undoInput(runtime flagView, token string) (map[string]interface{}, error) { + op := strings.TrimSpace(runtime.Str("op")) + stepsSet := runtime.Changed("steps") + + if op != "" && stepsSet { + return nil, common.FlagErrorf("--steps and --op are mutually exclusive") + } + + input := map[string]interface{}{"excel_id": token} + + if op != "" { + if err := validate.RejectControlChars(op, "op"); err != nil { + return nil, common.FlagErrorf("%v", err) + } + input["operation_id"] = op + return input, nil + } + + steps := runtime.Int("steps") + if steps < 1 { + return nil, common.FlagErrorf("--steps must be >= 1") + } + input["steps"] = steps + return input, nil +} diff --git a/shortcuts/sheets/lark_sheet_undo_test.go b/shortcuts/sheets/lark_sheet_undo_test.go new file mode 100644 index 000000000..cdb8fc437 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_undo_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "strings" + "testing" +) + +// TestUndo_DryRun asserts the undo_last body for the three selection shapes: +// default (steps=1), explicit --steps, and --op. Numbers round-trip through +// the wire JSON as float64, matching the other dry-run body tests. +func TestUndo_DryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantInput map[string]interface{} + }{ + { + name: "default undoes last step", + args: []string{"--url", testURL}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "steps": float64(1), + }, + }, + { + name: "explicit --steps", + args: []string{"--url", testURL, "--steps", "3"}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "steps": float64(3), + }, + }, + { + name: "--op targets a specific operation", + args: []string{"--spreadsheet-token", testToken, "--op", "op_5"}, + wantInput: map[string]interface{}{ + "excel_id": testToken, + "operation_id": "op_5", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + body := parseDryRunBody(t, Undo, tt.args) + got := decodeToolInput(t, body, "undo_last") + assertInputEquals(t, got, tt.wantInput) + }) + } +} + +// TestUndo_Validation covers the XOR token check, the --steps/--op +// mutual exclusion, and the --steps lower bound. +func TestUndo_Validation(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + args []string + wantMsg string + }{ + { + name: "needs --url or --spreadsheet-token", + args: []string{}, + wantMsg: "at least one of --url or --spreadsheet-token", + }, + { + name: "--steps and --op are mutually exclusive", + args: []string{"--url", testURL, "--steps", "2", "--op", "op_5"}, + wantMsg: "mutually exclusive", + }, + { + name: "--steps must be >= 1", + args: []string{"--url", testURL, "--steps", "0"}, + wantMsg: "--steps must be >= 1", + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + stdout, stderr, err := runShortcutCapturingErr(t, Undo, append(tt.args, "--dry-run")) + if err == nil { + t.Fatalf("expected validation error; got nil. stdout=%s stderr=%s", stdout, stderr) + } + combined := stdout + stderr + err.Error() + if !strings.Contains(combined, tt.wantMsg) { + t.Errorf("error message missing %q; got=%s", tt.wantMsg, combined) + } + }) + } +} diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go index eb4368476..8a9ae6e1e 100644 --- a/shortcuts/sheets/sheet_ai_api.go +++ b/shortcuts/sheets/sheet_ai_api.go @@ -7,6 +7,8 @@ import ( "context" "encoding/json" "fmt" + "os" + "strings" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/util" @@ -14,6 +16,24 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +// sheetTxnIDEnv is the env var carrying a caller-provided, session-stable +// transaction id for sheet tool calls. +const sheetTxnIDEnv = "LARK_CLI_SHEET_TRANSACTION_ID" + +// sheetTransactionID returns the session-stable transaction id from the +// environment, or "" when unset. +// +// Sheet write tools persist their reverse ("undo") changeset keyed by the +// request's transaction id; the server mints a fresh uuid per request when the +// caller supplies none, which isolates every CLI invocation into its own +// single-call undo stack. Threading one stable id across a group of edits (and +// a later +undo) is what lets +undo find and reverse those edits. An agent +// driving lark-cli sets this once per session; empty preserves today's +// per-request behavior. +func sheetTransactionID() string { + return strings.TrimSpace(os.Getenv(sheetTxnIDEnv)) +} + // ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket. // // - ToolKindRead → POST .../tools/invoke_read (scope sheets:spreadsheet:read, 10 qps) @@ -44,10 +64,17 @@ func buildToolBody(toolName string, input map[string]interface{}) (map[string]in if err != nil { return nil, fmt.Errorf("encode tool input: %w", err) } - return map[string]interface{}{ + body := map[string]interface{}{ "tool_name": toolName, "input": string(inputJSON), - }, nil + } + // Thread a session-stable transaction id (when provided) so a group of + // edits and a later +undo share one undo stack. Omitted when unset, leaving + // the server to mint a per-request id as before. + if txID := sheetTransactionID(); txID != "" { + body["extra"] = map[string]interface{}{"transaction_id": txID} + } + return body, nil } // callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes diff --git a/shortcuts/sheets/sheet_ai_api_test.go b/shortcuts/sheets/sheet_ai_api_test.go new file mode 100644 index 000000000..d0b268d12 --- /dev/null +++ b/shortcuts/sheets/sheet_ai_api_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import "testing" + +// cellsSetArgs is a minimal valid +cells-set invocation used to inspect the +// tool-call request body. +func cellsSetArgs() []string { + return []string{ + "--spreadsheet-token", testToken, + "--sheet-id", testSheetID, + "--range", "A1", + "--cells", `[[{"value":"x"}]]`, + } +} + +// TestBuildToolBody_ThreadsTransactionID verifies that a session-stable +// transaction id from the environment is threaded into the request body's +// extra.transaction_id, so a group of edits and a later +undo share one undo +// stack. +func TestBuildToolBody_ThreadsTransactionID(t *testing.T) { + t.Setenv(sheetTxnIDEnv, "tx_test_123") + body := parseDryRunBody(t, CellsSet, cellsSetArgs()) + extra, ok := body["extra"].(map[string]interface{}) + if !ok { + t.Fatalf("extra missing from body: %#v", body) + } + if extra["transaction_id"] != "tx_test_123" { + t.Errorf("transaction_id = %#v, want tx_test_123", extra["transaction_id"]) + } +} + +// TestBuildToolBody_OmitsTransactionIDWhenUnset verifies the body carries no +// extra when the env var is empty, preserving the per-request default. +func TestBuildToolBody_OmitsTransactionIDWhenUnset(t *testing.T) { + t.Setenv(sheetTxnIDEnv, "") + body := parseDryRunBody(t, CellsSet, cellsSetArgs()) + if _, ok := body["extra"]; ok { + t.Errorf("extra should be absent when %s is unset: %#v", sheetTxnIDEnv, body) + } +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 005d03292..347c73e3b 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -61,6 +61,9 @@ func shortcutList() []common.Shortcut { DropdownGet, TableGet, + // lark_sheet_undo + Undo, + // lark_sheet_search_replace CellsSearch, CellsReplace, From 66c16758ec5808ed60335a6851baca855fdad129 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Mon, 8 Jun 2026 19:07:11 +0800 Subject: [PATCH 2/5] fix(sheets): only thread transaction_id into write tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildToolBody attached extra.transaction_id to every tool invocation, including read tools (get_cell_ranges, get_range_as_csv, search_data, get_workbook_structure, ...). A read scoped to a transaction id resolves against that transaction's snapshot instead of the live document, so reads returned blank cells whenever LARK_CLI_SHEET_TRANSACTION_ID was set. Gate the extra block to ToolKindWrite — the undo stack only ever concerns writes — by threading kind into buildToolBody at every call site. Adds a regression test that read tools omit the transaction id. --- shortcuts/sheets/lark_sheet_object_crud.go | 2 +- shortcuts/sheets/lark_sheet_table_io.go | 6 +++--- shortcuts/sheets/lark_sheet_workbook.go | 4 ++-- shortcuts/sheets/lark_sheet_write_cells.go | 2 +- shortcuts/sheets/sheet_ai_api.go | 17 +++++++++++------ shortcuts/sheets/sheet_ai_api_test.go | 14 ++++++++++++++ 6 files changed, 32 insertions(+), 13 deletions(-) diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 221b9d03d..2de762837 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -688,7 +688,7 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH // With a local --image, Execute first uploads the file; surface that // extra step in the preview (mirrors +cells-set-image's dry-run). if img := strings.TrimSpace(runtime.Str("image")); img != "" { - manageBody, _ := buildToolBody("manage_float_image_object", input) + manageBody, _ := buildToolBody(ToolKindWrite, "manage_float_image_object", input) return common.NewDryRunAPI(). POST("/open-apis/drive/v1/medias/upload_all"). Desc("upload local image to drive (parent_type=sheet_image)"). diff --git a/shortcuts/sheets/lark_sheet_table_io.go b/shortcuts/sheets/lark_sheet_table_io.go index 18436a392..798454b6a 100644 --- a/shortcuts/sheets/lark_sheet_table_io.go +++ b/shortcuts/sheets/lark_sheet_table_io.go @@ -740,7 +740,7 @@ func tablePutDryRun(runtime *common.RuntimeContext) *common.DryRunAPI { if s.AllowOverwrite != nil && !*s.AllowOverwrite { input["allow_overwrite"] = false } - wireBody, _ := buildToolBody("set_cell_range", input) + wireBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", input) dry.POST(toolInvokePath(token, ToolKindWrite)).Desc(desc).Body(wireBody) } return dry @@ -777,14 +777,14 @@ var TableGet = common.Shortcut{ token, _ := resolveSpreadsheetToken(runtime) dry := common.NewDryRunAPI() if strings.TrimSpace(runtime.Str("sheet-id")) == "" && strings.TrimSpace(runtime.Str("sheet-name")) == "" { - body, _ := buildToolBody("get_workbook_structure", map[string]interface{}{"excel_id": token}) + body, _ := buildToolBody(ToolKindRead, "get_workbook_structure", map[string]interface{}{"excel_id": token}) dry.POST(toolInvokePath(token, ToolKindRead)).Desc("list sub-sheets via get_workbook_structure").Body(body) } rng := strings.TrimSpace(runtime.Str("range")) if rng == "" { rng = "" } - body, _ := buildToolBody("get_cell_ranges", map[string]interface{}{ + body, _ := buildToolBody(ToolKindRead, "get_cell_ranges", map[string]interface{}{ "excel_id": token, "ranges": []string{rng}, "include_styles": true, "value_render_option": "raw_value", }) diff --git a/shortcuts/sheets/lark_sheet_workbook.go b/shortcuts/sheets/lark_sheet_workbook.go index 509f93021..ef64edccb 100644 --- a/shortcuts/sheets/lark_sheet_workbook.go +++ b/shortcuts/sheets/lark_sheet_workbook.go @@ -634,7 +634,7 @@ var WorkbookCreate = common.Shortcut{ "range": tablePutFullRange(s, len(matrix)), "cells": matrix, } - wireBody, _ := buildToolBody("set_cell_range", input) + wireBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", input) dry.POST("/open-apis/sheet_ai/v2/spreadsheets//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) @@ -645,7 +645,7 @@ var WorkbookCreate = common.Shortcut{ if fill, _ := buildInitialFillInput(runtime); fill != nil { fill["excel_id"] = "" fill["sheet_id"] = "" // resolved from the workbook at execute time - wireBody, _ := buildToolBody("set_cell_range", fill) + wireBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", fill) dry.POST("/open-apis/sheet_ai/v2/spreadsheets//tools/invoke_write"). Desc("fill headers + data via set_cell_range (sheet_id resolved after create)"). Body(wireBody) diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 07768614f..986efaf93 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -727,7 +727,7 @@ var CellsSetImage = common.Shortcut{ if fileName == "" { fileName = filepath.Base(imgPath) } - setCellBody, _ := buildToolBody("set_cell_range", map[string]interface{}{ + setCellBody, _ := buildToolBody(ToolKindWrite, "set_cell_range", map[string]interface{}{ "excel_id": token, "range": strings.TrimSpace(runtime.Str("range")), "sheet_id": sheetSelectorPlaceholder(sheetID, sheetName), diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go index 8a9ae6e1e..0f4ee4530 100644 --- a/shortcuts/sheets/sheet_ai_api.go +++ b/shortcuts/sheets/sheet_ai_api.go @@ -59,7 +59,7 @@ func toolInvokePath(token string, kind ToolKind) string { // buildToolBody constructs the One-OpenAPI request body for a tool invocation. // `input` is serialized to a JSON string per the API contract; callers pass // a typed Go map and never need to handle JSON encoding themselves. -func buildToolBody(toolName string, input map[string]interface{}) (map[string]interface{}, error) { +func buildToolBody(kind ToolKind, toolName string, input map[string]interface{}) (map[string]interface{}, error) { inputJSON, err := json.Marshal(input) if err != nil { return nil, fmt.Errorf("encode tool input: %w", err) @@ -70,9 +70,14 @@ func buildToolBody(toolName string, input map[string]interface{}) (map[string]in } // Thread a session-stable transaction id (when provided) so a group of // edits and a later +undo share one undo stack. Omitted when unset, leaving - // the server to mint a per-request id as before. - if txID := sheetTransactionID(); txID != "" { - body["extra"] = map[string]interface{}{"transaction_id": txID} + // the server to mint a per-request id as before. Only write tools join the + // undo transaction; reads must never carry it — a read scoped to a + // transaction id resolves against that transaction's (often empty) snapshot + // instead of the live document, so it would read back blank. + if kind == ToolKindWrite { + if txID := sheetTransactionID(); txID != "" { + body["extra"] = map[string]interface{}{"transaction_id": txID} + } } return body, nil } @@ -92,7 +97,7 @@ func callTool( toolName string, input map[string]interface{}, ) (interface{}, error) { - body, err := buildToolBody(toolName, input) + body, err := buildToolBody(kind, toolName, input) if err != nil { return nil, err } @@ -136,7 +141,7 @@ func invokeToolDryRun( toolName string, input map[string]interface{}, ) *common.DryRunAPI { - wireBody, _ := buildToolBody(toolName, input) + wireBody, _ := buildToolBody(kind, toolName, input) return common.NewDryRunAPI(). POST(toolInvokePath(token, kind)). Body(wireBody). diff --git a/shortcuts/sheets/sheet_ai_api_test.go b/shortcuts/sheets/sheet_ai_api_test.go index d0b268d12..383d76dfb 100644 --- a/shortcuts/sheets/sheet_ai_api_test.go +++ b/shortcuts/sheets/sheet_ai_api_test.go @@ -41,3 +41,17 @@ func TestBuildToolBody_OmitsTransactionIDWhenUnset(t *testing.T) { t.Errorf("extra should be absent when %s is unset: %#v", sheetTxnIDEnv, body) } } + +// TestBuildToolBody_OmitsTransactionIDForReads verifies that read tools never +// carry a transaction id even when one is set: a read scoped to a transaction +// resolves against that transaction's snapshot (often empty) instead of the +// live document, so threading it would make reads return blank cells. +func TestBuildToolBody_OmitsTransactionIDForReads(t *testing.T) { + t.Setenv(sheetTxnIDEnv, "tx_test_123") + body := parseDryRunBody(t, CellsGet, []string{ + "--url", testURL, "--sheet-id", testSheetID, "--range", "A1", + }) + if _, ok := body["extra"]; ok { + t.Errorf("read tool must not carry extra.transaction_id: %#v", body) + } +} From a042942f7eb58827588a629c9ae78a799931034f Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Tue, 9 Jun 2026 18:39:22 +0800 Subject: [PATCH 3/5] feat(sheets): add +recover shortcut (full-document revision rollback) +recover --to-revision N rolls the whole spreadsheet back to a past revision via the recover_to_revision write tool (facade reuses its existing ProcessRecoverCs / revert-by-revision path). Distinct from +undo, which is precise and this-link-only; +recover is a full-document restore that discards all later edits, so it carries no sheet selector and a prominent overwrite warning in --help. Adds the +recover flag-defs entry (url / spreadsheet-token / to-revision) to both data/flag-defs.json and the compiled flag_defs_gen.go. --- shortcuts/sheets/data/flag-defs.json | 33 ++++++++++ shortcuts/sheets/flag_defs_gen.go | 9 +++ shortcuts/sheets/lark_sheet_recover.go | 85 ++++++++++++++++++++++++++ shortcuts/sheets/shortcuts.go | 3 + 4 files changed, 130 insertions(+) create mode 100644 shortcuts/sheets/lark_sheet_recover.go diff --git a/shortcuts/sheets/data/flag-defs.json b/shortcuts/sheets/data/flag-defs.json index 3913f98fc..9993918b9 100644 --- a/shortcuts/sheets/data/flag-defs.json +++ b/shortcuts/sheets/data/flag-defs.json @@ -40,6 +40,39 @@ } ] }, + "+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": "" + } + ] + }, "+workbook-info": { "risk": "read", "flags": [ diff --git a/shortcuts/sheets/flag_defs_gen.go b/shortcuts/sheets/flag_defs_gen.go index e213b6ac6..6f850d7dc 100644 --- a/shortcuts/sheets/flag_defs_gen.go +++ b/shortcuts/sheets/flag_defs_gen.go @@ -946,6 +946,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"}, + }, + }, "+workbook-create": { Risk: "write", Flags: []flagDef{ diff --git a/shortcuts/sheets/lark_sheet_recover.go b/shortcuts/sheets/lark_sheet_recover.go new file mode 100644 index 000000000..24f414c38 --- /dev/null +++ b/shortcuts/sheets/lark_sheet_recover.go @@ -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 +} diff --git a/shortcuts/sheets/shortcuts.go b/shortcuts/sheets/shortcuts.go index 347c73e3b..bbc7c9064 100644 --- a/shortcuts/sheets/shortcuts.go +++ b/shortcuts/sheets/shortcuts.go @@ -64,6 +64,9 @@ func shortcutList() []common.Shortcut { // lark_sheet_undo Undo, + // lark_sheet_recover + Recover, + // lark_sheet_search_replace CellsSearch, CellsReplace, From 41e6acba11379bd79a2ea2cee73467ffbcbd3926 Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 10 Jun 2026 11:22:36 +0800 Subject: [PATCH 4/5] feat(sheets): derive a session-stable transaction id for undo grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without LARK_CLI_SHEET_TRANSACTION_ID set, every CLI write received a fresh server-minted transaction id, so a group of edits and a later +undo never shared an undo stack and +undo could not reach the prior writes. Resolve the write tool's extra.transaction_id in three tiers: 1. $LARK_CLI_SHEET_TRANSACTION_ID — explicit caller override. 2. else a value derived from the OS session (getsid on unix, falling back to the parent pid; salted with uid and boot/host) so edits in one shell session group by default, with no env var to set. Each invocation is a fresh process and recomputes the same id rather than persisting one. 3. else "" — the server mints a per-request id as before. The derivation never needs the spreadsheet token (undo read-back is already keyed by token + transaction id), so buildToolBody keeps its signature and reads still never carry the id. --- shortcuts/sheets/sheet_ai_api.go | 66 ++++++++++++++++++++--- shortcuts/sheets/sheet_ai_api_test.go | 34 +++++++++--- shortcuts/sheets/sheet_session_unix.go | 32 +++++++++++ shortcuts/sheets/sheet_session_windows.go | 21 ++++++++ 4 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 shortcuts/sheets/sheet_session_unix.go create mode 100644 shortcuts/sheets/sheet_session_windows.go diff --git a/shortcuts/sheets/sheet_ai_api.go b/shortcuts/sheets/sheet_ai_api.go index 0f4ee4530..da0eafa28 100644 --- a/shortcuts/sheets/sheet_ai_api.go +++ b/shortcuts/sheets/sheet_ai_api.go @@ -5,9 +5,12 @@ package sheets import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" + "strconv" "strings" "github.com/larksuite/cli/internal/output" @@ -20,18 +23,65 @@ import ( // transaction id for sheet tool calls. const sheetTxnIDEnv = "LARK_CLI_SHEET_TRANSACTION_ID" -// sheetTransactionID returns the session-stable transaction id from the -// environment, or "" when unset. +// sheetTransactionID returns the session-stable transaction id threaded into a +// write tool call's extra.transaction_id. // // Sheet write tools persist their reverse ("undo") changeset keyed by the // request's transaction id; the server mints a fresh uuid per request when the -// caller supplies none, which isolates every CLI invocation into its own -// single-call undo stack. Threading one stable id across a group of edits (and -// a later +undo) is what lets +undo find and reverse those edits. An agent -// driving lark-cli sets this once per session; empty preserves today's -// per-request behavior. +// caller supplies none, which would isolate every CLI invocation into its own +// single-call undo stack. Sharing one stable id across a group of edits (and a +// later +undo) is what lets +undo find and reverse those edits. +// +// Resolution order: +// 1. $LARK_CLI_SHEET_TRANSACTION_ID — explicit caller override (highest). +// 2. else a value derived from this shell session (see +// deriveSessionTransactionID) so a group of edits and a later +undo group +// by default, with no env var to set. +// 3. else "" — the server mints a per-request id as before. func sheetTransactionID() string { - return strings.TrimSpace(os.Getenv(sheetTxnIDEnv)) + if v := strings.TrimSpace(os.Getenv(sheetTxnIDEnv)); v != "" { + return v + } + return deriveSessionTransactionID() +} + +// deriveSessionTransactionID builds a transaction id that is stable across the +// lark-cli invocations of one shell session and distinct across sessions, so a +// group of edits and a later +undo share an undo stack without the caller +// exporting LARK_CLI_SHEET_TRANSACTION_ID. +// +// Each lark-cli run is a fresh process and cannot mutate its parent's +// environment, so a *generated* id can't survive to the next command. Instead +// every run independently *recomputes* the same id from its own OS session — +// nothing is persisted between invocations. +// +// Returns "" when no trustworthy session signal exists (e.g. the process was +// reparented to init); the server then mints a per-request id and a +// missing-grouping +undo surfaces undone:0 rather than silently grouping +// unrelated callers. The grouping signal is a per-shell-session token +// (sessionSignal, platform-specific) salted with the uid and boot/host so a +// session id recycled after a reboot, or reused by a different user, can't +// collide with a stale undo stack. +func deriveSessionTransactionID() string { + sig, ok := sessionSignal() + if !ok { + return "" + } + seed := strings.Join([]string{sig, strconv.Itoa(os.Getuid()), sessionSalt()}, "|") + sum := sha256.Sum256([]byte(seed)) + return "larkcli-" + hex.EncodeToString(sum[:16]) +} + +// sessionSalt pins the derived id to this boot (Linux boot_id) or, failing +// that, this host, so a session id recycled after a reboot can't address a +// pre-reboot undo stack. Best-effort: an empty salt only weakens collision +// resistance across reboots, never correctness within a session. +func sessionSalt() string { + if b, err := os.ReadFile("/proc/sys/kernel/random/boot_id"); err == nil { + return strings.TrimSpace(string(b)) + } + h, _ := os.Hostname() + return h } // ToolKind selects the One-OpenAPI endpoint and its rate-limit bucket. diff --git a/shortcuts/sheets/sheet_ai_api_test.go b/shortcuts/sheets/sheet_ai_api_test.go index 383d76dfb..76e13d6e2 100644 --- a/shortcuts/sheets/sheet_ai_api_test.go +++ b/shortcuts/sheets/sheet_ai_api_test.go @@ -3,7 +3,10 @@ package sheets -import "testing" +import ( + "strings" + "testing" +) // cellsSetArgs is a minimal valid +cells-set invocation used to inspect the // tool-call request body. @@ -32,13 +35,32 @@ func TestBuildToolBody_ThreadsTransactionID(t *testing.T) { } } -// TestBuildToolBody_OmitsTransactionIDWhenUnset verifies the body carries no -// extra when the env var is empty, preserving the per-request default. -func TestBuildToolBody_OmitsTransactionIDWhenUnset(t *testing.T) { +// TestBuildToolBody_DerivesTransactionIDWhenUnset verifies that with the env +// var unset a write tool carries a session-derived transaction id (so a group +// of edits and a later +undo group by default), that the derived id is stable +// across invocations in the same session, and that it differs from any literal +// override. In an environment with no trustworthy session signal the derived +// id is "" and the body carries no extra, preserving the per-request default. +func TestBuildToolBody_DerivesTransactionIDWhenUnset(t *testing.T) { t.Setenv(sheetTxnIDEnv, "") + want := sheetTransactionID() body := parseDryRunBody(t, CellsSet, cellsSetArgs()) - if _, ok := body["extra"]; ok { - t.Errorf("extra should be absent when %s is unset: %#v", sheetTxnIDEnv, body) + extra, hasExtra := body["extra"].(map[string]interface{}) + + if want == "" { + if hasExtra { + t.Errorf("no session signal: extra should be absent: %#v", body) + } + return + } + if !strings.HasPrefix(want, "larkcli-") { + t.Errorf("derived transaction_id = %q, want larkcli- prefix", want) + } + if !hasExtra || extra["transaction_id"] != want { + t.Errorf("write tool should carry derived transaction_id %q: %#v", want, body) + } + if got := sheetTransactionID(); got != want { + t.Errorf("derived transaction_id not stable: %q vs %q", got, want) } } diff --git a/shortcuts/sheets/sheet_session_unix.go b/shortcuts/sheets/sheet_session_unix.go new file mode 100644 index 000000000..d4e3472e6 --- /dev/null +++ b/shortcuts/sheets/sheet_session_unix.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build !windows + +package sheets + +import ( + "os" + "strconv" + "syscall" +) + +// sessionSignal returns a token that is stable across every process of one +// shell/login session and distinct across sessions, plus ok=false when no such +// signal is trustworthy. +// +// The POSIX session id (getsid) is preferred: every process in the same +// terminal/login session shares it, and unlike the parent pid it survives +// subshell wrapping (e.g. `sh -c "lark-cli ..."` spawned afresh per command), +// which is the common way an agent drives the CLI. It falls back to the parent +// pid, then gives up when the process was reparented to init (sid/ppid <= 1) — +// init is shared by unrelated processes and would over-group distinct callers. +func sessionSignal() (string, bool) { + if sid, err := syscall.Getsid(0); err == nil && sid > 1 { + return "sid:" + strconv.Itoa(sid), true + } + if ppid := os.Getppid(); ppid > 1 { + return "ppid:" + strconv.Itoa(ppid), true + } + return "", false +} diff --git a/shortcuts/sheets/sheet_session_windows.go b/shortcuts/sheets/sheet_session_windows.go new file mode 100644 index 000000000..80acb9858 --- /dev/null +++ b/shortcuts/sheets/sheet_session_windows.go @@ -0,0 +1,21 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +//go:build windows + +package sheets + +import ( + "os" + "strconv" +) + +// sessionSignal returns a per-session grouping token. Windows has no POSIX +// session id, so the parent process id is the best portable signal; ok=false +// when the process has no real parent (ppid <= 1). +func sessionSignal() (string, bool) { + if ppid := os.Getppid(); ppid > 1 { + return "ppid:" + strconv.Itoa(ppid), true + } + return "", false +} From c1ee8613e41b457824130e855d39a42f51ca477c Mon Sep 17 00:00:00 2001 From: zhengzhijie Date: Wed, 10 Jun 2026 12:02:34 +0800 Subject: [PATCH 5/5] fix(sheets): use x/sys/unix.Getsid so linux builds compile The stdlib syscall package exposes Getsid on darwin/BSD but not on linux, so the session-id derivation broke linux cross-compilation. Switch to golang.org/x/sys/unix.Getsid (already a direct dependency), and narrow the build tag from !windows to unix to match where that package is available. Verified all six release targets (darwin/linux/windows x amd64/arm64) build. --- shortcuts/sheets/sheet_session_unix.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/shortcuts/sheets/sheet_session_unix.go b/shortcuts/sheets/sheet_session_unix.go index d4e3472e6..aee4aa481 100644 --- a/shortcuts/sheets/sheet_session_unix.go +++ b/shortcuts/sheets/sheet_session_unix.go @@ -1,14 +1,15 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -//go:build !windows +//go:build unix package sheets import ( "os" "strconv" - "syscall" + + "golang.org/x/sys/unix" ) // sessionSignal returns a token that is stable across every process of one @@ -18,11 +19,13 @@ import ( // The POSIX session id (getsid) is preferred: every process in the same // terminal/login session shares it, and unlike the parent pid it survives // subshell wrapping (e.g. `sh -c "lark-cli ..."` spawned afresh per command), -// which is the common way an agent drives the CLI. It falls back to the parent -// pid, then gives up when the process was reparented to init (sid/ppid <= 1) — -// init is shared by unrelated processes and would over-group distinct callers. +// which is the common way an agent drives the CLI. Getsid comes from +// x/sys/unix because the stdlib syscall package does not expose it on Linux. +// It falls back to the parent pid, then gives up when the process was +// reparented to init (sid/ppid <= 1) — init is shared by unrelated processes +// and would over-group distinct callers. func sessionSignal() (string, bool) { - if sid, err := syscall.Getsid(0); err == nil && sid > 1 { + if sid, err := unix.Getsid(0); err == nil && sid > 1 { return "sid:" + strconv.Itoa(sid), true } if ppid := os.Getppid(); ppid > 1 {