mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
be2a96f490
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
252 lines
7.9 KiB
Go
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
|
|
}
|