Files
larksuite__cli/shortcuts/sheets/flag_schema.go
xiongyuanwen-byted be2a96f490 feat(sheets): harden error prescriptions, batch updates, and read workflows
Aggregate the sheets work from feat/lark-sheets-develop:

- Improve validation errors with schema hints, aggregated issues, enum guidance, and prescriptive flag/style-field messages.
- Harden +batch-update input contracts, key normalization, style vocabulary handling, and resource-budget checks.
- Add read offload and truncation handling for cells, csv, and table-get, with typed output-path errors and safer jq/output-path semantics.
- Correct freeze semantics by emitting full-state freeze/unfreeze operations and adding --rows/--cols for +dim-freeze.
- Improve +styles-put and shared --styles parsing for styles, merges, row/column sizing, freeze, and sheet-prefixed range validation.
- Fix dim-insert inherit-style mapping, table-get date/time handling, table-put style anchors, and CSV path-shaped input guards.
- Update lark-sheets skill docs, scripts, tests, and generated flag data.

Tested with:
- go test ./shortcuts/common ./shortcuts/sheets/...
- go test ./shortcuts/... ./internal/...
- python3 -m py_compile skills/lark-sheets/scripts/*.py
2026-08-07 11:21:12 +08:00

252 lines
7.9 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
_ "embed"
"encoding/json"
"sort"
"strings"
"sync"
"github.com/larksuite/cli/errs"
)
// ─── --print-schema runtime introspection ─────────────────────────────
//
// Composite JSON flags (--cells, --properties, --operations, --border-styles,
// --sort-keys) carry non-trivial structured payloads. Reference docs cover
// the top-level fields but agents often need the full JSON Schema to
// generate valid input.
//
// To serve that need without forcing every caller to fetch external docs,
// the spec repo ships a compact `flag-schemas.json` that extracts just the
// schema subtree corresponding to each (shortcut, flag) pair. We embed
// that artifact at compile time so `lark-cli sheets <shortcut>
// --print-schema --flag-name <name>` runs entirely locally.
//
// The artifact is generated by sheet-skill-spec's
// scripts/sync_to_consumers.mjs from canonical-spec/cli-flag-schema-map.json
// + tool-schemas/mcp-tools.json. Do not hand-edit data/flag-schemas.json;
// regenerate via the sync script.
//go:embed data/flag-schemas.json
var flagSchemasJSON []byte
// flagSchemaIndex parses lazily on first access; failures are surfaced
// as errors from the lookup helper rather than panicking at init time.
type flagSchemaIndex struct {
SchemaVersion string `json:"schema_version"`
Flags map[string]map[string]json.RawMessage `json:"flags"`
}
// loadFlagSchemas is sync.Once-guarded so concurrent first access from
// parallel goroutines (e.g. parallel unit tests, parallel shortcut
// invocations) doesn't race on the lazy parse.
var (
flagSchemasOnce sync.Once
parsedFlagSchemas *flagSchemaIndex
parseFlagErr error
)
func loadFlagSchemas() (*flagSchemaIndex, error) {
flagSchemasOnce.Do(func() {
var idx flagSchemaIndex
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err)
return
}
if idx.Flags == nil {
idx.Flags = map[string]map[string]json.RawMessage{}
}
parsedFlagSchemas = &idx
})
return parsedFlagSchemas, parseFlagErr
}
// commandsWithFlagSchema returns the set of shortcut commands that have
// at least one introspectable flag. Used by Shortcuts() to decide which
// shortcuts to wire PrintFlagSchema into.
func commandsWithFlagSchema() map[string]struct{} {
idx, err := loadFlagSchemas()
if err != nil || idx == nil {
return nil
}
out := make(map[string]struct{}, len(idx.Flags))
for cmd := range idx.Flags {
out[cmd] = struct{}{}
}
return out
}
// printFlagSchemaFor returns a PrintFlagSchema closure bound to the given
// shortcut command. When flagName == "" the closure returns a JSON
// listing of introspectable flags; otherwise it returns the schema
// subtree JSON for the named flag, or an error if the flag is not
// registered.
//
// flagName also accepts a dotted path (properties.plotArea.axes): the
// first segment names the flag, the rest walk the schema's properties
// (descending through array items implicitly), returning just that
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
// lines — otherwise force agents to page through the full dump for one
// nested field; eval traces show 25 such round trips in one batch.
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return func(flagName string) ([]byte, error) {
idx, err := loadFlagSchemas()
if err != nil {
return nil, err
}
entry, ok := idx.Flags[command]
if !ok || len(entry) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no JSON Schema registered for %s", command)
}
if flagName == "" {
flags := make([]string, 0, len(entry))
for f := range entry {
flags = append(flags, f)
}
sort.Strings(flags)
return json.MarshalIndent(map[string]interface{}{
"shortcut": command,
"introspectable_flags": flags,
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
}, "", " ")
}
name, path := splitSchemaPath(flagName)
schema, ok := entry[name]
if !ok {
// Tolerate the wire-vocabulary underscore form (--flag-name
// border_styles for border-styles) — agents copy field names out
// of JSON payloads where underscores are canonical.
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
schema, ok = entry[alt]
}
}
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
flags = append(flags, f)
}
sort.Strings(flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
WithParam("--flag-name")
}
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
if len(path) > 0 {
pretty, err = sliceSchemaByPath(pretty, name, path)
if err != nil {
return nil, err
}
}
// Reformat for readability — schema files store compact JSON.
return json.MarshalIndent(pretty, "", " ")
}
}
// splitSchemaPath splits a --flag-name value into the flag name and the
// optional dotted schema path after it.
func splitSchemaPath(flagName string) (string, []string) {
parts := strings.Split(flagName, ".")
return parts[0], parts[1:]
}
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
// Each segment matches a key under "properties"; array levels are descended
// implicitly through "items" (an explicit "items" segment also works), and
// oneOf branches are searched for the first one carrying the key. A miss
// errors with the keys actually available at that level so the caller can
// re-issue the path without a full dump.
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
node := schema
walked := flagName
for _, seg := range path {
next, ok := schemaChild(node, seg)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
WithParam("--flag-name")
}
node = next
walked += "." + seg
}
return node, nil
}
// schemaChild resolves one path segment against a schema node, descending
// through items / oneOf wrappers as needed.
func schemaChild(node interface{}, seg string) (interface{}, bool) {
for depth := 0; depth < 8; depth++ {
m, ok := node.(map[string]interface{})
if !ok {
return nil, false
}
if seg == "items" {
if items, ok := m["items"]; ok {
return items, true
}
}
if props, ok := m["properties"].(map[string]interface{}); ok {
if child, ok := props[seg]; ok {
return child, true
}
}
if items, ok := m["items"]; ok {
node = items
continue
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
return nil, false
}
return nil, false
}
// schemaChildKeys lists the property keys reachable at a schema node (through
// items / oneOf wrappers), for the path-miss error.
func schemaChildKeys(node interface{}) []string {
seen := map[string]struct{}{}
var collect func(n interface{}, depth int)
collect = func(n interface{}, depth int) {
if depth > 8 {
return
}
m, ok := n.(map[string]interface{})
if !ok {
return
}
if props, ok := m["properties"].(map[string]interface{}); ok {
for k := range props {
seen[k] = struct{}{}
}
return
}
if items, ok := m["items"]; ok {
collect(items, depth+1)
return
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
collect(b, depth+1)
}
}
}
collect(node, 0)
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}