Compare commits

...

11 Commits

Author SHA1 Message Date
zhengzhijie
5f381aa439 refactor(sheets): drop +recover flag/shortcut registration (split to PR #1414) 2026-06-11 22:00:49 +08:00
zhengzhijie
251635ec1e refactor(sheets): split recover into its own PR; keep only undo here
+recover (方案B) moved to its own PR (#1414). This PR (方案A) keeps +undo and
the read/write transaction_id split. The recover_to_revision server impl lives
in the facade recover MR.
2026-06-11 21:59:23 +08:00
zhengzhijie
4936a983bf docs(sheets): clarify LARK_CLI_SHEET_TRANSACTION_ID is concurrency-isolation only
+undo locates edits by document revision (rev pointers / --rev), not by
transaction id, so the old comment claiming a shared id "is what lets +undo
find and reverse those edits" was wrong and misleading. Rewrite to state undo
is rev-addressed and this env var's only role is optional concurrency isolation
(opt-in shared undo stack); empty stays the per-request default.
2026-06-11 20:18:08 +08:00
zhengzhijie
886cca6032 feat(sheets): anchor +undo by revision (--rev) instead of --op
Undo is now addressed by the document revision a prior write returned
(write responses carry data.revision): +undo --rev <n> reverses exactly
the edit that produced that revision, rejected if the document has
moved past it; omitting --rev targets the latest edit. --steps N still
reverses the last N edits in one atomic call.

--op is removed: operation ordinals were scoped to a per-session
transaction id that fresh-shell agent harnesses cannot thread across
commands, so the handle was unreachable in practice. The revision
anchor needs no session state at all.
2026-06-10 22:37:32 +08:00
zhengzhijie
b64018a672 Revert "feat(sheets): derive a session-stable transaction id for undo grouping"
This reverts commit 41e6acba11.
2026-06-10 19:35:35 +08:00
zhengzhijie
1996b67451 Revert "fix(sheets): use x/sys/unix.Getsid so linux builds compile"
This reverts commit c1ee8613e4.
2026-06-10 19:35:35 +08:00
zhengzhijie
c1ee8613e4 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.
2026-06-10 12:02:34 +08:00
zhengzhijie
41e6acba11 feat(sheets): derive a session-stable transaction id for undo grouping
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.
2026-06-10 11:22:36 +08:00
zhengzhijie
a042942f7e 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.
2026-06-09 18:39:22 +08:00
zhengzhijie
66c16758ec fix(sheets): only thread transaction_id into write tool calls
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.
2026-06-08 19:07:11 +08:00
zhengzhijie
b42db647ff 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
2026-06-08 15:06:58 +08:00
11 changed files with 372 additions and 12 deletions

View File

@@ -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); one step = one prior write call",
"default": "1"
},
{
"name": "rev",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Undo anchor: the document revision returned by a prior write's response (`data.revision`). Omit to undo the latest edit. Doubles as an optimistic-concurrency check — rejected if the document has moved past this revision"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+workbook-info": {
"risk": "read",
"flags": [

View File

@@ -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); one step = one prior write call", Default: "1"},
{Name: "rev", Kind: "own", Type: "int", Required: "optional", Desc: "Undo anchor: the document revision returned by a prior write's response (`data.revision`). Omit to undo the latest edit. Doubles as an optimistic-concurrency check — rejected if the document has moved past this revision"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{

View File

@@ -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)").

View File

@@ -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 = "<each sheet's current region>"
}
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",
})

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"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, addressed
// by document revision. Every write response carries `data.revision`; that
// number is the undo anchor. The backend records an inverse changeset for every
// write and indexes it by the revision it produced (see the undo design doc,
// "方案 A · rev 寻址"); +undo asks the backend executor to locate that inverse
// data through the revision pointer, verify nobody else changed the document
// since (tip / continuity / object-version / identity checks), re-apply it in
// reverse order on the node Workbook, and 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. Selection:
// - (no flags) : undo the latest edit, if it was made by this caller
// - --rev N : undo anchored at revision N (from a prior write response);
// rejected when the document has moved past N
// - --steps N : undo the last N edits in one atomic call (default 1)
// Undo wraps undo_last: reverse the most recent edits made through this CLI
// link, anchored by the revision a prior write returned (--rev), defaulting
// to the latest edit.
var Undo = common.Shortcut{
Service: "sheets",
Command: "+undo",
Description: "Undo the most recent edits this CLI link made to a spreadsheet (anchored by a write's returned revision).",
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{
"Every write response carries data.revision — remember it; +undo --rev <that> undoes exactly that edit, and +recover --to-revision <that-1> is the full-rollback fallback.",
"Without --rev, +undo targets the document's latest edit — it succeeds only when that edit was made through this CLI link by you.",
"Repeated +undo steps back one edit at a time; --steps N undoes the last N edits in one atomic call. Already-undone edits are skipped automatically.",
"If anyone else edited the document after (or between) the edits you want to undo, +undo refuses entirely and suggests +recover — it never partially undoes or overwrites others' changes.",
"A success response with undone:0 plus warning_message means nothing was actually undone — the targeted revision wasn't produced by this caller, or was already undone.",
"Use --dry-run to preview the request before running it.",
},
}
// undoInput builds the undo_last tool body. --rev anchors the undo at the
// revision a prior write returned (omitted = latest); --steps selects how many
// edits to reverse in one atomic call. Network-free; shared by Validate,
// DryRun, and Execute.
func undoInput(runtime flagView, token string) (map[string]interface{}, error) {
input := map[string]interface{}{"excel_id": token}
if runtime.Changed("rev") {
rev := runtime.Int("rev")
if rev < 1 {
return nil, common.FlagErrorf("--rev must be a positive revision number (from a prior write's data.revision)")
}
input["rev"] = rev
}
steps := runtime.Int("steps")
if steps < 1 {
return nil, common.FlagErrorf("--steps must be >= 1")
}
input["steps"] = steps
return input, nil
}

View File

@@ -0,0 +1,107 @@
// 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 (latest, steps=1), explicit --steps, and a --rev anchor. 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 the latest edit",
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: "--rev anchors at a write's returned revision",
args: []string{"--spreadsheet-token", testToken, "--rev", "123"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"rev": float64(123),
"steps": float64(1),
},
},
{
name: "--rev composes with --steps",
args: []string{"--url", testURL, "--rev", "123", "--steps", "2"},
wantInput: map[string]interface{}{
"excel_id": testToken,
"rev": float64(123),
"steps": float64(2),
},
},
}
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 --rev lower bound, 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: "--rev must be positive",
args: []string{"--url", testURL, "--rev", "0"},
wantMsg: "--rev must be a positive revision number",
},
{
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)
}
})
}
}

View File

@@ -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/<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)
@@ -645,7 +645,7 @@ var WorkbookCreate = common.Shortcut{
if fill, _ := buildInitialFillInput(runtime); fill != nil {
fill["excel_id"] = "<new-token>"
fill["sheet_id"] = "<first-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/<new-token>/tools/invoke_write").
Desc("fill headers + data via set_cell_range (sheet_id resolved after create)").
Body(wireBody)

View File

@@ -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),

View File

@@ -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,26 @@ 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 optional per-session transaction id from the
// environment, or "" when unset.
//
// NOTE: +undo does NOT use this id to locate edits — the server addresses undo
// by document revision (the `rev` a write returns; see +undo --rev), not by
// transaction id. This env var's only purpose is optional concurrency
// isolation: write tools persist their reverse ("undo") changeset keyed by the
// request's transaction id, and the server mints a fresh uuid per request when
// none is supplied, so each invocation lands in its own undo stack by default.
// Set a stable id across commands only to deliberately share one isolated undo
// stack across a group of edits; empty preserves the per-request default and is
// the norm.
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)
@@ -39,15 +61,27 @@ 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)
}
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. 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
}
// callTool invokes a sheet-ai tool via the One-OpenAPI endpoint and decodes
@@ -65,7 +99,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
}
@@ -109,7 +143,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).

View File

@@ -0,0 +1,57 @@
// 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)
}
}
// 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)
}
}

View File

@@ -61,6 +61,9 @@ func shortcutList() []common.Shortcut {
DropdownGet,
TableGet,
// lark_sheet_undo
Undo,
// lark_sheet_search_replace
CellsSearch,
CellsReplace,