Compare commits

..

14 Commits

Author SHA1 Message Date
shanglei
5cd34b1f70 Merge branch 'main' into feat/shortcut-protocol 2026-06-03 13:50:45 +08:00
shanglei
9a53a1f2b8 fix(shortcuts): typed []string named-element binding + nil-Execute mount parity
Two parity edge cases from a follow-up audit:

- []<named string> element types (type ID string; field []ID) passed parse
  validation but panicked at bind via reflect.Convert([]string -> []ID). Build
  the slice element-by-element with SetString (stringsToSlice) so plain
  []string, named slice types and named element types all bind correctly.
- nil Execute: mountTyped now skips mounting (matching legacy
  Shortcut.MountWithContext) instead of mounting a command that errors only at
  invocation, keeping the command tree identical after migration.

Adds binder_namedslice_nilexec_test.go (4 cases).
2026-05-29 15:30:20 +08:00
shanglei
eb6f5aa60a feat(shortcuts): typed []string flags, per-flag hidden, @file help hint
Close the last legacy/typed capability gaps so migration never downgrades:

- []string fields: registerLeaf/bindLeaf gain a Slice case. Default maps to
  cobra StringSlice (comma-separated, repeatable); `split:"none"` opts into
  StringArray (repeatable, no comma split). Non-[]string slices and split on
  non-slice fields error at Mount time. bucketLeafValue handles slices in
  OneOf/group too.
- per-flag hidden: `hidden:"true"` tag → fieldSpec.Hidden → MarkHidden;
  typed help skips hidden flags (parity with legacy Flag.Hidden).
- @file/stdin discoverability: typed help now appends a (supports @file /
  - for stdin) hint for input-tagged flags, matching legacy.

Adds binder_slice_hidden_test.go (11 cases).
2026-05-29 15:15:09 +08:00
shanglei
c4eb18cecc feat(shortcuts): typed @file/stdin input + enum completion/help parity
Bring TypedShortcut[T] to parity with legacy common.Shortcut on two flag
capabilities that were silently missing on the typed path:

- @file / stdin: declare `input:"file,stdin"` on an Args field; the binder
  resolves @path (file) and - (stdin) before binding, recursing into OneOf
  buckets and groups. resolveInputForFlag is extracted from the legacy
  resolveInputFlags so both paths behave identically.
- enum: registerLeaf registers shell completion and typed help renders the
  candidate list, matching legacy. enum/input tags on non-string fields now
  error at Mount time instead of being silently skipped at runtime.

Legacy behavior unchanged. Adds binder_input_enum_test.go (11 cases).
2026-05-29 14:55:40 +08:00
shanglei
a510e07dfc refactor(shortcuts): drop unused argstype.UserOpenIDList
UserOpenIDList ([]string) had zero production callers and cannot be bound
as a typed flag field: the binder only handles string/bool/int scalars, so
a []string field panics at bind time (reflect.Value.Convert: string cannot
convert to []string). Remove the type, its ParseUserOpenIDList helper and
the test instead of special-casing slice binding for a type nobody uses.
2026-05-29 11:51:16 +08:00
shanglei
f83c79825d refactor(shortcuts): retire Maybe[T] — top-level *T pointer covers tri-state
Maybe[T] solved a real problem (distinguishing "user did not provide this
flag" from "user provided the type's zero value, e.g. --notify=false or
--limit 0"), but it added a framework-specific wrapper that every migrator
had to learn. Two independent dogfood migrations both flagged it as the
guide's biggest cognitive bump.

This change retires Maybe[T] and reuses the existing OneOf pointer
convention at the top level: *T now means "optional with tri-state
semantics — nil iff the user did not set the flag". The rule is the same
whether the *T sits inside a OneOf bucket (Chat *ChatID — variant not
selected) or at the top level of an Args struct (Notify *bool — flag not
given), so users learn one concept instead of two.

Code:
- binder.go: bindLeaf now gates pointer allocation behind
  cmd.Flags().Changed(name); previously a *T top-level pointer was always
  allocated, defeating the "nil = not given" semantic. fieldSpec loses the
  IsMaybe field; parseFieldSpec drops Maybe-shape detection; registerFlags
  / bindFlags drop their IsMaybe branches; the bindMaybe helper is deleted.
- protocol.go: Maybe[T] type removed.
- protocol_test.go: TestMaybe_UnsetVsZero removed.
- binder_coverage_test.go: bindMaybe tests replaced with two
  TestBindLeaf_Ptr* tests covering the new "*T top-level pointer" contract
  — nil when absent, non-nil with the user's value (including the tri-state
  case --notify=false) when explicitly set.

Verified: go build ./..., go test ./shortcuts/common/..., gofmt clean, and
`go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` vs origin/main
shows zero new dead code.
2026-05-28 19:29:34 +08:00
shanglei
9adc79d0c1 fix(shortcuts): typed help shows REQUIRED + defaults; restore TypedShortcut.Mount
Three fixes surfaced by a dogfood migration of an existing legacy shortcut
to the TypedShortcut framework.

1. Required flags rendered under OPTIONAL
   The typed help renderer had no notion of required vs optional and lumped
   every top-level leaf into a single OPTIONAL section. Add renderRequiredSection
   that lists fields whose `required` tag is set under a REQUIRED: header; the
   updated renderOptionalSection skips those so each leaf appears in exactly
   one section.

2. Default values silently dropped
   Fields tagged `default:"x"` showed no `(default "x")` suffix, unlike cobra's
   legacy default help. Add a formatLeafLine helper that appends `(default "x")`
   whenever fieldSpec.DefaultValue is non-empty; the REQUIRED, OPTIONAL, and
   CHOOSE ONE bucket renderers all reuse it for consistent information density.

3. TypedShortcut.Mount was missing
   Legacy common.Shortcut exposes BOTH .Mount(parent, factory) and
   .MountWithContext. The framework dropped .Mount in 8d8acb82 to satisfy
   deadcode, but a dogfood migration revealed the asymmetry forces every
   migrating shortcut's tests to also switch to MountWithContext. Restore the
   3-line Mount convenience method (delegates to MountWithContext with a
   background context) and add a unit test exercising it directly — the test
   doubles as API documentation and keeps deadcode happy.

Tests added:
- TestTypedShortcut_Mount: verifies the legacy-shaped Mount(parent, factory)
  call still wires the subcommand
- TestTypedHelp_RequiredSectionAndDefaults: asserts REQUIRED comes before
  OPTIONAL, --limit lands in REQUIRED not OPTIONAL, `(default "20")` appears
  for --page-size, and a plain --verbose carries no default suffix

Verified locally: go build ./..., go test ./shortcuts/common/..., gofmt -l,
and `go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` vs
origin/main — all pass with zero new dead code.
2026-05-28 17:24:58 +08:00
shanglei
6b4bc0cc64 style(shortcuts): gofmt binder_test.go
CI fast-gate's `gofmt -l .` flagged an alignment issue in contentBucket:
gofmt aligns struct field tags to the longest *named* tag-bearing field
in the group; my hand-written extra spaces in `Text *string   \`flag:"ct"\``
got normalised to a single space.

Behaviour-neutral; tests still pass.
2026-05-28 16:51:17 +08:00
shanglei
b5cd535285 refactor(shortcuts): drop oneof_trigger tag, infer variant attempt from any inner flag
The previous checkOneOf required group / bucket variants inside a OneOf
to mark exactly one inner field with `oneof_trigger:"true"`, so the
framework could identify "the flag that signals the variant was selected".
This forces the Args writer to think about an implementation detail and
produces a misleading shortcut_oneof_missing when a user supplies only a
companion field (e.g. --video-cover without --video) — the user actually
attempted that variant, but the framework couldn't see it.

Replace the explicit trigger with implicit detection: any inner flag of a
group / bucket variant that is Changed counts as attempting that variant.
The follow-up checkGroup catches the partial-fill case and surfaces a
shortcut_group_incomplete pointing at the missing flag, which is strictly
more useful than the prior oneof_missing.

Code changes:
- binder.go: drop fieldSpec.OneOfTrig, drop parseFieldSpec's oneof_trigger
  tag handling, rewrite checkOneOf with the inferred-attempt logic, delete
  the isTrigger helper (now inlined and simplified).
- typed_help.go: renderFlagsInBucket flattens all inner flags to the
  parent's indent (no more "trigger vs companion" visual distinction,
  matching the framework's new semantics).
- binder_test.go: add three behavioural tests covering the new contract:
  (1) companion alone surfaces group_incomplete (not oneof_missing),
  (2) full group passes,
  (3) simple variant + group companion both attempted yields oneof_multiple.

Verified: go build ./..., go test ./shortcuts/common/...,
`go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` against HEAD
vs origin/main yields zero new dead code.

Net: -1 tag, -1 field on fieldSpec, -1 helper function, simpler writer
ergonomics, more accurate error messages.
2026-05-28 16:42:40 +08:00
shanglei
098659cc18 feat(shortcuts): bind top-level group sub-struct fields in TypedShortcut
bindFlags and bindBuckets only populated top-level leaves and OneOf
buckets; a top-level group sub-struct (a regular nested struct without
OneOf() marker) had its inner flags registered and group-completeness
checked, but its field values were never written back into the Args
struct — Execute would read empty values from args.Group.X.

Add bindGroups as the top-level counterpart to bindBuckets for IsGroup
fields, and invoke it from mountTyped's Validate closure right after
bindBuckets. Behavior mirrors bindBuckets / bindBucketInner:

- Value-type top-level group: always populated; inner fields receive
  cobra flag values (including defaults).
- Pointer-type top-level group: allocated iff at least one inner flag
  was Changed, so a nil group still signals "user did not engage this
  group" while a non-nil group means "the user opted into it".

Tests cover four cases: value group with explicit flags, value group
with defaults applied, pointer group allocated when an inner flag is
set, pointer group left nil when nothing is set.

Verified: go build ./..., go test ./shortcuts/common/..., and
`go run golang.org/x/tools/cmd/deadcode@v0.31.0 -test ./...` against
HEAD vs origin/main yields zero new dead code.

This closes the previously-documented limitation that group sub-structs
had to be nested inside an OneOf bucket to actually bind values.
2026-05-28 15:58:54 +08:00
shanglei
8d8acb8252 test(shortcuts): cover TypedShortcut descriptors, drop unused Mount
After reverting the im pilot in ccf654d3 the TypedShortcut framework
has no production caller, so the CI deadcode check flagged five
methods as newly unreachable. Resolve without breaking the framework:

- Remove TypedShortcut.Mount — a convenience wrapper over
  MountWithContext with zero callers (production OR test); not part
  of any interface contract.
- Add focused unit tests for the four ShortcutDescriptor methods
  required by the Mountable contract — GetAuthTypes plus
  ScopesForIdentity / ConditionalScopesForIdentity /
  DeclaredScopesForIdentity, with table-driven coverage of the
  user / bot / fallback / dedupe branches.

Locally verified: go run golang.org/x/tools/cmd/deadcode@v0.31.0
-test ./... against HEAD vs origin/main yields an empty diff.
2026-05-28 10:57:39 +08:00
shanglei
ccf654d3f0 revert(shortcuts): drop im messages-send typed pilot, keep framework
The im +messages-send TypedShortcut pilot was exploratory only; revert it
while retaining the TypedShortcut framework for future migrations.

- shortcuts/im: restored to pre-pilot state (im_messages_send.go back to
  legacy common.Shortcut; deleted protocol.go, protocol_test.go,
  im_messages_send_test.go; shortcuts.go drops TypedShortcuts()).
- shortcuts/register.go: removed addTyped(im.TypedShortcuts()) wiring and
  the now-unused addTyped helper; legacy addLegacy + Mountable dispatch
  retained.
- shortcuts/common, argstype, errs subtypes, cmd/auth adapters: kept.
- framework doc comments: replaced examples referencing the removed pilot
  types (MessageTarget/MessageContent/VideoContent/RawContent) with neutral
  descriptions; noted no typed shortcut is registered today.

Framework now has no production caller. Verified: go build ./... and
go test ./shortcuts/... ./errs/... ./cmd/auth/... ./cmd/ all pass.
2026-05-27 18:16:17 +08:00
shanglei
ad4368ed2a test(shortcuts): strengthen auth-type assertion + cover binder paths
- im_messages_send_test: assert auth-type set membership (reject
  duplicates / missing members) per CodeRabbit review
- binder_coverage_test: cover bindMaybe, bindBuckets / bindBucketInner,
  bucketLeafValue, runNormalize / asString, checkGroup,
  checkEnumAndRequired, and group recursion in runValidateValue — lifts
  patch coverage over the 60% gate
2026-05-27 15:32:58 +08:00
shanglei
a07239b923 feat(shortcuts): introduce TypedShortcut framework + im send pilot
Add strongly-typed shortcut protocol that coexists with the legacy
common.Shortcut. The new common.TypedShortcut[T] is a generic outer
wrapper backed by a reflect-driven binder; both legacy and typed
shortcuts satisfy a new common.Mountable / common.ShortcutDescriptor
interface pair so register.go can dispatch either through the same
pipeline.

Framework (shortcuts/common):
- protocol.go — Mountable / ShortcutDescriptor / OneOfMarker /
  Validatable / Normalizable[T] / ArgsValidator / Maybe[T] /
  HelpExample
- binder.go — reflect Args walk, intra-Args flag-tag uniqueness
  panic, cobra flag registration, bindFlags + bindMaybe, runNormalize
  (via MethodByName dispatch — Normalizable[T] can't be type-asserted
  through a non-generic interface), runValidateValue, runFrameworkRules
  for required / enum / OneOf / group
- typed_shortcut.go — TypedShortcut[T] struct, 8 descriptor methods,
  mountTyped adapter that synthesizes a legacy Shortcut shell and
  reuses runShortcut verbatim (identity / scopes / @file / stdin / jq
  / dry-run / high-risk gate)
- typed_help.go — sectioned --help (CHOOSE ONE / OPTIONAL / EXAMPLES)
  with cmdutil.GetRisk/GetTips passthrough so typed shortcuts keep the
  Risk: and Tips: blocks
- runner.go — typedArgs lifecycle slot on RuntimeContext
- types.go — 5 GetX accessors on *Shortcut so legacy shortcuts
  satisfy ShortcutDescriptor alongside the existing pointer-receiver
  scope methods

Typed primitives (shortcuts/common/argstype):
- ChatID / UserOpenID / UserOpenIDList — prefix-validated identifiers
- SafePath — cwd-relative, rejects absolute paths and ".." segments
- MediaInput — tri-state (URL bypass / img_xxx-file_xxx key bypass /
  SafePath delegation)
- SpreadsheetRef — Normalize extracts shtcn token from feishu URLs

Error contract (errs):
- 3 new Subtype constants: shortcut_oneof_missing /
  shortcut_oneof_multiple / shortcut_group_incomplete. Per-field
  failures (required / enum / typed primitive format) reuse the
  existing SubtypeInvalidArgument so no new error type is introduced.

Registry refactor (shortcuts + cmd):
- AllShortcuts() now returns []common.ShortcutDescriptor; legacy
  shortcuts are boxed as *Shortcut (pointer required for the
  pointer-receiver scope methods), typed shortcuts boxed as Mountable
- Register dispatches via the Mountable interface
- cmd/auth/login, cmd/auth/login_interactive, cmd/error_auth_hint,
  cmd/diagnose_scope_test, shortcuts/register_test (shortcuts.json
  generator) updated to read through GetService / GetCommand /
  GetAuthTypes / GetDescription / DeclaredScopesForIdentity
- shortcutSupportsIdentity helpers accept ShortcutDescriptor

Pilot (shortcuts/im):
- protocol.go — MessageTarget / MessageContent (with seven content
  variants) / VideoContent (paired video + cover) / RawContent
  (--content with explicit msg-type, validates JSON in
  ValidateValue)
- im_messages_send.go — migrated to TypedShortcut[*ImMessagesSendArgs].
  Inline Validate closure is replaced by framework-derived checks
  (OneOf target, OneOf content, VideoContent group, typed-primitive
  formats, RawContent JSON). Helpers (resolveMediaContent,
  wrapMarkdownAsPostForDryRun, normalizeAtMentions, etc.) reused
  verbatim; only the field-access pattern changes from
  runtime.Str("x") to args.X.
- shortcuts.go — new TypedShortcuts() exporter; ImMessagesSend
  removed from the legacy Shortcuts() slice so it is not
  double-mounted
- register.go wires addTyped(im.TypedShortcuts()) into init

Known follow-ups:
- runFrameworkRules and bindFlags do not recurse into OneOf bucket /
  group sub-structs; im messages-send compensates with a local
  bindMessagesSendArgs + validateVideoGroup. Generalizing the binder
  to recurse will let future migrations drop the local shim.
- common.ValidateChatID / common.ValidateUserID become redundant
  once all legacy shortcuts that call them migrate; can be retired
  with the last legacy caller.

Refs: docs/superpowers/specs/2026-05-26-shortcut-protocol-design.md
2026-05-27 11:32:06 +08:00
193 changed files with 5930 additions and 34498 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/

View File

@@ -2,26 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
@@ -1009,7 +989,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44

View File

@@ -90,7 +90,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.Flags().StringVar(&opts.File, "file", "", "file to upload as multipart/form-data ([field=]path, supports - for stdin)")

View File

@@ -718,23 +718,3 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
}
}
func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}

View File

@@ -527,10 +527,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true
}
@@ -557,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
if !registry.HasAuthDomain(sc.GetService()) {
domains[sc.GetService()] = true
}
}
return domains
@@ -580,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
if len(authTypes) == 0 {
authTypes = []string{"user"}
}

View File

@@ -64,12 +64,13 @@ func getDomainMetadata(lang string) []domainMeta {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
dm := buildDomainMeta(sc.Service, lang)
svc := sc.GetService()
if !seen[svc] {
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
dm := buildDomainMeta(svc, lang)
domains = append(domains, dm)
}
seen[sc.Service] = true
seen[svc] = true
}
}

View File

@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
sc := &common.Shortcut{AuthTypes: nil}
if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected default to support 'user'")
}
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
}
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected to support 'user'")
}
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
}
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
sc := common.Shortcut{AuthTypes: []string{"bot"}}
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
if shortcutSupportsIdentity(sc, "user") {
t.Error("expected bot-only to NOT support 'user'")
}

View File

@@ -117,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {

View File

@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
seen[p] = true
}
for _, s := range shortcuts.AllShortcuts() {
if s.Service != "" {
seen[s.Service] = true
if s.GetService() != "" {
seen[s.GetService()] = true
}
}
result := make([]string, 0, len(seen))
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
}
for _, sc := range allSC {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
continue
}
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "shortcut",
Method: sc.Command,
Method: sc.GetCommand(),
Scope: scope, Identity: []string{identity},
}
}
@@ -148,11 +148,12 @@ func diagBuild(domains []string) diagOutput {
return diagOutput{Methods: methods, Scopes: scopes}
}
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
if len(sc.AuthTypes) == 0 {
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
if len(authTypes) == 0 {
return identity == "user"
}
for _, a := range sc.AuthTypes {
for _, a := range authTypes {
if a == identity {
return true
}

View File

@@ -105,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
service := cmd.Parent().Name()
for _, sc := range shortcuts.AllShortcuts() {
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.DeclaredScopesForIdentity(identity)
@@ -154,8 +154,8 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
// shortcutSupportsIdentity reports whether a shortcut supports the requested
// identity, applying the default user-only behavior when AuthTypes is empty.
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
if len(authTypes) == 0 {
authTypes = []string{string(core.AsUser)}
}

View File

@@ -10,7 +10,6 @@ import (
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
)
const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0})
continue
}
if d := suggest.Levenshtein(input, def.Key); d <= threshold {
if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d})
}
}
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.",
)
}
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

@@ -10,6 +10,27 @@ import (
_ "github.com/larksuite/cli/events"
)
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"a", "", 1},
{"", "abc", 3},
{"kitten", "kitten", 0},
{"kitten", "sitten", 1},
{"kitten", "sitting", 3},
{"飞书", "飞书", 0},
{"飞书", "飞s", 1},
}
for _, tc := range cases {
if got := levenshtein(tc.a, tc.b); got != tc.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
}
}
}
func TestSuggestEventKeys(t *testing.T) {
cases := []struct {
name string

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"slices"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
func TestUnknownFlagName(t *testing.T) {
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --query", "query", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagName(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagName(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().String("find", "", "")
c.Flags().Bool("dry-run", false, "")
err := flagDidYouMean(c, errors.New("unknown flag: --rang")) // typo of --range
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("type = %q, want unknown_flag", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "--range") {
t.Errorf("hint should suggest --range, got %q", exitErr.Detail.Hint)
}
detail, _ := exitErr.Detail.Detail.(map[string]any)
valid, _ := detail["valid_flags"].([]string)
if !slices.Contains(valid, "find") || !slices.Contains(valid, "range") {
t.Errorf("valid_flags should list find & range, got %v", valid)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Detail.Type != "flag_error" {
t.Errorf("type = %q, want flag_error (non-unknown-flag errors stay generic)", exitErr.Detail.Type)
}
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/deprecation"
)
// composePendingNotice must surface a deprecated-command alias under the
// "deprecated_command" key, with the migration target and a skill-update hint,
// so the JSON "_notice" envelope reaches users who run pre-refactor commands
// without ever reading --help.
func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command key: %#v", got)
}
if entry["command"] != "+read" {
t.Errorf("command = %v, want +read", entry["command"])
}
if entry["replacement"] != "+cells-get" {
t.Errorf("replacement = %v, want +cells-get", entry["replacement"])
}
if entry["skill"] != "lark-sheets" {
t.Errorf("skill = %v, want lark-sheets", entry["skill"])
}
if msg, _ := entry["message"].(string); !strings.Contains(msg, "update your lark-sheets skill") {
t.Errorf("message missing skill-update hint: %q", msg)
}
}
// With nothing pending, the provider returns nil so no "_notice" field is
// emitted on a clean run.
func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {
t.Fatalf("deprecated_command present after clear: %#v", got)
}
}
}

View File

@@ -18,17 +18,14 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -72,15 +69,7 @@ COMMUNITY:
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
// from here. It stays nil in unit tests that invoke a RunE directly with
// explicit args — correct, since those don't exercise the whitelist path.
var rawInvocationArgs []string
func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
@@ -144,49 +133,29 @@ func setupNotices() {
skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
output.PendingNotice = func() map[string]interface{} {
notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
return nil
}
return notice
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if dep := deprecation.GetPending(); dep != nil {
entry := map[string]interface{}{
"command": dep.Command,
"message": dep.Message(),
"action": "lark-cli update",
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
}
if dep.Skill != "" {
entry["skill"] = dep.Skill
}
notice["deprecated_command"] = entry
}
if len(notice) == 0 {
return nil
}
return notice
}
// isCompletionCommand returns true if args indicate a shell completion request.
@@ -291,19 +260,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code
}
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err)
return 1
}
@@ -345,12 +301,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
@@ -370,89 +320,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
// like the global --profile, legitimately prints help. But a flag that
// belongs to a (missing) subcommand is a user error: the guard's
// FParseErrWhitelist swallows such flags and leaves args empty, so without
// the checks below they would silently fall through to help + exit 0 —
// letting an agent mistake a malformed call (`im --format json`,
// `sheets --badflag`) for success. Recover the swallowed tokens from the
// raw invocation and fail structured instead.
flags := flagTokensInArgs(rawInvocationArgs)
if len(flags) == 0 {
return cmd.Help()
}
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
}
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
// require a subcommand, so a bare group carrying only those still prints
// help. Anything left belongs to a subcommand that was omitted
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
// real, the subcommand is what's missing.
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
if len(misplaced) == 0 {
return cmd.Help()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
}
return cmd.Help()
}
unknown := args[0]
available, deprecated := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
available := availableSubcommandNames(cmd)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)",
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
}
return &output.ExitError{
Code: output.ExitValidation,
@@ -460,114 +335,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: detail,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
},
}
}
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in
// rawArgs, stopping at the "--" positional terminator. Whether a flag is
// defined is not considered (see unknownFlagTokens for that). A pure group
// with any flag token but no subcommand is a user error — a pure group
// consumes no flags of its own, so the flag must belong to a subcommand — so
// the caller fails structured instead of falling through to help.
func flagTokensInArgs(rawArgs []string) []string {
var toks []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
toks = append(toks, a)
}
return toks
}
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
// the suggestion path; the side effect is that flags before a subcommand are
// swallowed. This recovers the genuinely-unknown ones so the caller can name
// them in a "did you mean" envelope.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
// group and therefore not requiring a subcommand.
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
short := len(name) == 1
lookup := func(fs *pflag.FlagSet) bool {
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
}
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
// omitting the subcommand they belong to (`im --format json`). Global flags
// valid on the bare group (e.g. --profile) are excluded so
// `lark-cli --profile p im` still prints help rather than erroring.
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var misplaced []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name == "" || flagKnownOnGroup(cmd, name) {
continue
}
if flagDefinedInTree(cmd, name) {
misplaced = append(misplaced, a)
}
}
return misplaced
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
@@ -576,95 +354,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
if name == "help" || name == "completion" {
continue
}
if cmdutil.IsDeprecatedCommand(c) {
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
subs = append(subs, name)
}
sort.Strings(available)
sort.Strings(deprecated)
return available, deprecated
}
// flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It
// converts cobra's flag-parse errors into the structured ErrorEnvelope: an
// unknown flag gets a focused "did you mean" hint plus the full valid-flag list
// in detail (so agents recover even when the typo is semantic, e.g. --query vs
// --find, where edit distance alone finds nothing). Other flag errors stay
// structured but generic.
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "flag_error",
Message: ferr.Error(),
Hint: fmt.Sprintf("run `%s --help` for valid flags", c.CommandPath()),
},
}
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if len(suggestions) > 0 {
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint = fmt.Sprintf("did you mean %s? (run `%s --help` for all flags)",
strings.Join(suggestions, ", "), c.CommandPath())
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %q for %q", "--"+name, c.CommandPath()),
Hint: hint,
Detail: map[string]any{
"unknown": "--" + name,
"command_path": c.CommandPath(),
"suggestions": suggestions,
"valid_flags": valid,
},
},
}
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
// those structured but generic — hallucinated flags are essentially always long.
//
// CONTRACT: this matches cobra's English wording "unknown flag: --" (go.mod
// pins github.com/spf13/cobra). If cobra rewords this or gains i18n the match
// silently fails and unknown flags degrade to a generic flag_error — re-verify
// this prefix when bumping cobra.
func unknownFlagName(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names of c (for suggestions and
// the valid_flags detail).
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
sort.Strings(subs)
return subs
}
// installTipsHelpFunc wraps the default help function to append a TIPS section

View File

@@ -21,7 +21,6 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
)
@@ -269,54 +268,6 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// TestHandleRootError_DeprecatedAliasMissingFlagStructured pins issue #4: a
// backward-compat alias that fails on a cobra-level required flag (which
// short-circuits before RunE) still routes through the structured envelope,
// because OnInvoke records the deprecation in PreRunE and the legacy fallback
// switches to WriteErrorEnvelope when a deprecation is pending — so the
// migration notice is no longer dropped on the plain "Error:" line.
func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
deprecation.SetPending(&deprecation.Notice{
Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets",
})
// The bare error shape cobra's ValidateRequiredFlags produces: neither typed
// nor an *output.ExitError, so it reaches the legacy fallback.
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
t.Fatalf("deprecation pending: want a structured envelope, got a plain Error: line:\n%s", out)
}
if !strings.Contains(out, `"message"`) || !strings.Contains(out, "values") {
t.Errorf("expected a JSON error envelope carrying the failure message; got:\n%s", out)
}
}
// TestHandleRootError_NoDeprecationKeepsPlainError pins the other half: with no
// deprecation pending, the legacy fallback stays a plain "Error:" line, so the
// fix does not reshape every unrecognized cobra error.
func TestHandleRootError_NoDeprecationKeepsPlainError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
if !strings.HasPrefix(errOut.String(), "Error:") {
t.Errorf("no deprecation pending: want a plain 'Error:' line, got:\n%s", errOut.String())
}
}
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the

View File

@@ -180,7 +180,6 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().Bool("json", false, "shorthand for --format json")
cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" {

View File

@@ -765,22 +765,3 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--json"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--json should be accepted without error, got: %v", err)
}
if captured == nil {
t.Fatal("expected runF to be called")
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
@@ -73,149 +72,6 @@ func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
}
}
func TestUnknownFlagTokens(t *testing.T) {
_, drive, _ := newGroupTree()
// Give a subcommand a flag so a misplaced-but-known flag (the user omitted
// the subcommand) is distinguished from a genuinely unknown one.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
cases := []struct {
name string
rawArgs []string
want []string
}{
{"genuinely unknown long flag", []string{"drive", "--badflag"}, []string{"--badflag"}},
{"flag known on a subcommand (misplaced)", []string{"drive", "--query", "x"}, nil},
{"no flags at all", []string{"drive"}, nil},
{"tokens after -- are positional", []string{"drive", "--", "--badflag"}, nil},
{"unknown shorthand", []string{"drive", "-Z"}, []string{"-Z"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := unknownFlagTokens(drive, tc.rawArgs)
if len(got) != len(tc.want) {
t.Fatalf("unknownFlagTokens(%v) = %v, want %v", tc.rawArgs, got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("token[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestUnknownSubcommandRunE_FlagBeforeSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
// Simulate `lark-cli drive --badflag`: the UnknownFlags whitelist swallows
// --badflag, so RunE sees no args; the guard must recover it from
// rawInvocationArgs and fail structured rather than print help + exit 0.
rawInvocationArgs = []string{"drive", "--badflag"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured unknown_flag error, got nil (help fallthrough)")
}
if !strings.Contains(err.Error(), "unknown flag") {
t.Errorf("error = %q, want it to mention an unknown flag", err.Error())
}
// The detail must stay schema-compatible with flagDidYouMean's unknown_flag
// (same Type → same keys), so a consumer keyed on Type reads a stable shape.
exitErr, ok := err.(*output.ExitError)
if !ok || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError with Detail, got %T", err)
}
if exitErr.Detail.Type != "unknown_flag" {
t.Errorf("detail.Type = %q, want unknown_flag", exitErr.Detail.Type)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "--badflag" {
t.Errorf("detail.unknown = %v, want --badflag", detail["unknown"])
}
if got, _ := detail["unknown_flags"].([]string); len(got) != 1 || got[0] != "--badflag" {
t.Errorf("detail.unknown_flags = %v, want [--badflag]", detail["unknown_flags"])
}
for _, key := range []string{"suggestions", "valid_flags"} {
if _, present := detail[key]; !present {
t.Errorf("detail.%s missing; must be present (empty) to match the unknown_flag schema", key)
}
}
}
func TestUnknownSubcommandRunE_ValidFlagWithoutSubcommandIsStructured(t *testing.T) {
_, drive, _ := newGroupTree()
// --query is defined on the +search subcommand, so it is a *valid* flag that
// was placed before the (omitted) subcommand. Unlike an unknown flag, this
// must still fail structured (missing_subcommand) rather than fall through to
// help + exit 0 — `drive --query x` is a malformed call, not a help request.
for _, c := range drive.Commands() {
if c.Name() == "+search" {
c.Flags().String("query", "", "")
}
}
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"drive", "--query", "x"}
t.Cleanup(func() { rawInvocationArgs = nil })
err := drive.RunE(drive, nil)
if err == nil {
t.Fatal("expected a structured missing_subcommand error, got nil (help fallthrough)")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "missing_subcommand" {
t.Fatalf("detail.Type = %v, want missing_subcommand", exitErr.Detail)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if flags, _ := detail["flags"].([]string); len(flags) != 1 || flags[0] != "--query" {
t.Errorf("detail.flags = %v, want [--query]", detail["flags"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path = %v, want lark-cli drive", detail["command_path"])
}
}
// A bare group carrying only a group-valid global flag (e.g. the inherited
// --profile) is not missing a subcommand — those flags do not belong to a
// subcommand — so it must print help, not fail with missing_subcommand.
func TestUnknownSubcommandRunE_GroupValidGlobalFlagShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
drive.Root().PersistentFlags().String("profile", "", "") // global, inherited by drive
installUnknownSubcommandGuard(drive.Root())
rawInvocationArgs = []string{"--profile", "p", "drive"}
t.Cleanup(func() { rawInvocationArgs = nil })
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("bare group with only a global flag should print help, got error: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
@@ -257,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
// "+bogus" has no close neighbor among drive's subcommands, so the hint falls
// back to pointing at --help; the full machine-readable list lives in
// detail.available below (which also excludes hidden commands).
if !strings.Contains(exitErr.Detail.Hint, "--help") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint)
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
@@ -308,7 +164,7 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got, _ := availableSubcommandNames(root)
got := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
@@ -319,61 +175,3 @@ func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
}
}
}
func TestAvailableSubcommandNames_SplitsDeprecatedGroup(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
root.AddCommand(
&cobra.Command{Use: "+new-cmd", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+old-cmd", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
available, deprecated := availableSubcommandNames(root)
if len(available) != 1 || available[0] != "+new-cmd" {
t.Errorf("available = %v, want [+new-cmd]", available)
}
if len(deprecated) != 1 || deprecated[0] != "+old-cmd" {
t.Errorf("deprecated = %v, want [+old-cmd]", deprecated)
}
}
// unknownSubcommandRunE must split current vs deprecated subcommands into
// separate detail buckets, while suggestions still rank across both so a
// mistyped legacy alias resolves.
func TestUnknownSubcommandRunE_SplitsDeprecatedBucket(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
svc.AddGroup(&cobra.Group{ID: cmdutil.DeprecatedGroupID, Title: "Deprecated"})
svc.AddCommand(
&cobra.Command{Use: "+cells-get", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "+read", GroupID: cmdutil.DeprecatedGroupID, RunE: func(*cobra.Command, []string) error { return nil }},
)
err := unknownSubcommandRunE(svc, []string{"+reat"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("detail is not a map: %#v", exitErr.Detail.Detail)
}
if available, _ := detail["available"].([]string); len(available) != 1 || available[0] != "+cells-get" {
t.Errorf("available = %v, want [+cells-get]", available)
}
deprecated, ok := detail["deprecated"].([]string)
if !ok || len(deprecated) != 1 || deprecated[0] != "+read" {
t.Errorf("deprecated = %v, want [+read]", deprecated)
}
// suggestions rank across both buckets: "+reat" is closest to +read.
suggestions, _ := detail["suggestions"].([]string)
found := false
for _, s := range suggestions {
if s == "+read" {
found = true
}
}
if !found {
t.Errorf("suggestions %v should include +read (typo target)", suggestions)
}
}

14
errs/subtypes_shortcut.go Normal file
View File

@@ -0,0 +1,14 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
// cross-field semantic failures need their own subtype here; per-field
// failures (required missing / enum invalid / typed-primitive format) reuse
// SubtypeInvalidArgument.
const (
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
)

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "testing"
func TestShortcutSubtypes_Values(t *testing.T) {
tests := []struct {
name string
got Subtype
want string
}{
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.got) != tt.want {
t.Errorf("got %q, want %q", string(tt.got), tt.want)
}
})
}
}

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

@@ -5,7 +5,6 @@ package cmdpolicy
import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
)
// suggestRisk returns the closest valid Risk literal by edit distance
@@ -21,9 +20,9 @@ func suggestRisk(bad string) string {
platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
}
best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best)
bestDist := levenshtein(lowered, best)
for _, c := range candidates[1:] {
if d := suggest.Levenshtein(lowered, string(c)); d < bestDist {
if d := levenshtein(lowered, string(c)); d < bestDist {
bestDist, best = d, string(c)
}
}
@@ -41,3 +40,47 @@ func toLower(s string) string {
}
return string(b)
}
// levenshtein computes the classic edit distance between two strings.
// O(len(a)*len(b)) time, O(min(a,b)) space. Three-element string set
// makes raw performance irrelevant — clarity beats trickiness here.
func levenshtein(a, b string) int {
if len(a) == 0 {
return len(b)
}
if len(b) == 0 {
return len(a)
}
prev := make([]int, len(b)+1)
curr := make([]int, len(b)+1)
for j := 0; j <= len(b); j++ {
prev[j] = j
}
for i := 1; i <= len(a); i++ {
curr[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = min3(
prev[j]+1, // deletion
curr[j-1]+1, // insertion
prev[j-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[len(b)]
}
func min3(a, b, c int) int {
m := a
if b < m {
m = b
}
if c < m {
m = c
}
return m
}

View File

@@ -29,3 +29,23 @@ func TestSuggestRisk(t *testing.T) {
}
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"wrtie", "write", 2},
{"kitten", "sitting", 3},
}
for _, c := range cases {
got := levenshtein(c.a, c.b)
if got != c.want {
t.Errorf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}

View File

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
// DeprecatedGroupID is the cobra GroupID that marks a backward-compatibility
// command — one kept alive for users whose skill predates a refactor. Service
// registration assigns it (e.g. the sheets pre-refactor aliases); both --help
// rendering and unknown-subcommand suggestions read it to separate these
// aliases from the current commands.
const DeprecatedGroupID = "deprecated"
// IsDeprecatedCommand reports whether c was tagged into the deprecated group.
func IsDeprecatedCommand(c *cobra.Command) bool {
return c != nil && c.GroupID == DeprecatedGroupID
}

View File

@@ -1,57 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deprecation carries a process-level notice that the command currently
// being executed is a backward-compatibility alias, kept alive for users whose
// skill predates a refactor. The notice is surfaced in JSON output envelopes via
// output.PendingNotice (wired in cmd/root.go), mirroring internal/skillscheck.
//
// A CLI process runs exactly one shortcut, so a single process-level slot is
// sufficient: the command's Execute records the notice before producing output,
// and the output layer reads it back when building the envelope.
package deprecation
import (
"strings"
"sync/atomic"
)
// Notice describes a deprecated command alias and the current command that
// replaces it. Replacement and Skill are optional.
type Notice struct {
Command string `json:"command"`
Replacement string `json:"replacement,omitempty"`
Skill string `json:"skill,omitempty"`
}
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
var b strings.Builder
b.WriteString(n.Command)
b.WriteString(" is a pre-refactor compatibility alias")
if n.Replacement != "" {
b.WriteString("; use ")
b.WriteString(n.Replacement)
b.WriteString(" instead")
}
if n.Skill != "" {
b.WriteString("; update your ")
b.WriteString(n.Skill)
b.WriteString(" skill, run: lark-cli update")
} else {
b.WriteString("; update your skill, run: lark-cli update")
}
return b.String()
}
// pending stores the latest deprecation notice for the current process.
var pending atomic.Pointer[Notice]
// SetPending stores the notice for consumption by output decorators.
// Pass nil to clear.
func SetPending(n *Notice) { pending.Store(n) }
// GetPending returns the pending deprecation notice, or nil.
func GetPending() *Notice { return pending.Load() }

View File

@@ -1,58 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package deprecation
import "testing"
func TestNoticeMessage(t *testing.T) {
tests := []struct {
name string
notice Notice
want string
}{
{
name: "replacement and skill",
notice: Notice{Command: "+read", Replacement: "+cells-get", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no replacement",
notice: Notice{Command: "+read", Skill: "lark-sheets"},
want: "+read is a pre-refactor compatibility alias; update your lark-sheets skill, run: lark-cli update",
},
{
name: "no skill",
notice: Notice{Command: "+read", Replacement: "+cells-get"},
want: "+read is a pre-refactor compatibility alias; use +cells-get instead; update your skill, run: lark-cli update",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.notice.Message(); got != tt.want {
t.Errorf("Message() =\n %q\nwant\n %q", got, tt.want)
}
})
}
}
func TestSetGetPending(t *testing.T) {
t.Cleanup(func() { SetPending(nil) })
SetPending(nil)
if got := GetPending(); got != nil {
t.Fatalf("expected nil pending after clear, got %#v", got)
}
n := &Notice{Command: "+write", Replacement: "+cells-set", Skill: "lark-sheets"}
SetPending(n)
got := GetPending()
if got == nil || got.Command != "+write" || got.Replacement != "+cells-set" {
t.Fatalf("GetPending() = %#v, want %#v", got, n)
}
SetPending(nil)
if GetPending() != nil {
t.Fatal("expected nil after clearing")
}
}

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package suggest provides the shared "did you mean" primitives: a rune-aware
// Levenshtein edit distance and a prefix-weighted Closest ranker. It is the
// single home for these so cmd, cmd/event, and internal/cmdpolicy stop each
// carrying their own copy.
package suggest
import "sort"
// Levenshtein computes the classic edit distance between two strings. It is
// rune-aware, so it is correct for multi-byte input.
func Levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}
// Closest returns up to maxN of candidates that plausibly match typed, ranked
// by shared-prefix length (desc) then edit distance (asc), keeping only
// reasonably-close ones.
//
// Shared prefix is weighted first on purpose: hallucinated names are often
// semantically close but lexically far (e.g. "+cells-find" vs "+cells-search",
// "--with-styles" vs nothing close), where the common prefix is the strongest
// signal of intent that raw edit distance misses.
func Closest(typed string, candidates []string, maxN int) []string {
type scored struct {
name string
prefix int
dist int
}
limit := editLimit(typed)
ranked := make([]scored, 0, len(candidates))
for _, c := range candidates {
p := sharedPrefixLen(typed, c)
d := Levenshtein(typed, c)
// Keep only plausible matches: a meaningful shared prefix, or an edit
// distance within budget. Drop everything else so the hint stays short.
if p >= 3 || d <= limit {
ranked = append(ranked, scored{name: c, prefix: p, dist: d})
}
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].prefix != ranked[j].prefix {
return ranked[i].prefix > ranked[j].prefix
}
if ranked[i].dist != ranked[j].dist {
return ranked[i].dist < ranked[j].dist
}
return ranked[i].name < ranked[j].name
})
if maxN <= 0 || maxN > len(ranked) {
maxN = len(ranked)
}
out := make([]string, 0, maxN)
for _, s := range ranked[:maxN] {
out = append(out, s.name)
}
return out
}
// editLimit allows roughly one third of the typed length in edits (min 2), so
// short names tolerate a couple of typos and longer ones proportionally more.
func editLimit(s string) int {
if l := len([]rune(s)) / 3; l > 2 {
return l
}
return 2
}
func sharedPrefixLen(a, b string) int {
ra, rb := []rune(a), []rune(b)
n := 0
for n < len(ra) && n < len(rb) && ra[n] == rb[n] {
n++
}
return n
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package suggest
import (
"slices"
"testing"
)
func TestClosest_HallucinatedSharesPrefix(t *testing.T) {
cmds := []string{
"+cells-get", "+cells-set", "+cells-search", "+cells-replace",
"+cells-clear", "+cells-merge", "+csv-get", "+chart-create",
"+pivot-create", "+sheet-info",
}
// "+cells-find" is semantically +cells-search but lexically far; the shared
// "+cells-" prefix should still surface the right family (incl. +cells-search).
got := Closest("+cells-find", cmds, 6)
if len(got) == 0 || len(got) > 6 {
t.Fatalf("expected 1..6 suggestions, got %v", got)
}
if !slices.Contains(got, "+cells-search") {
t.Errorf("expected +cells-search among suggestions, got %v", got)
}
for _, s := range got {
if len(s) < 7 || s[:7] != "+cells-" {
t.Errorf("suggestion %q does not share the +cells- prefix", s)
}
}
}
func TestClosest_TypoRanksExactNeighborFirst(t *testing.T) {
got := Closest("+cell-get", []string{"+cells-get", "+cells-set", "+csv-get", "+sheet-info"}, 3)
if len(got) == 0 || got[0] != "+cells-get" {
t.Errorf("expected +cells-get first for typo +cell-get, got %v", got)
}
}
func TestClosest_NoPlausibleMatch(t *testing.T) {
if got := Closest("+zzzzzz", []string{"+cells-get", "+csv-get"}, 6); len(got) != 0 {
t.Errorf("expected no suggestions for unrelated input, got %v", got)
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "abc", 3},
{"abc", "", 3},
{"abc", "abc", 0},
{"kitten", "sitting", 3},
{"cell-get", "cells-get", 1},
{"--query", "--find", 5},
{"飞书", "飞书", 0}, // rune-aware: multi-byte equal
{"飞书", "飞s", 1}, // one rune substitution, not byte count
}
for _, c := range cases {
if d := Levenshtein(c.a, c.b); d != c.want {
t.Errorf("Levenshtein(%q,%q) = %d, want %d", c.a, c.b, d, c.want)
}
}
}
func TestSharedPrefixLen(t *testing.T) {
if got := sharedPrefixLen("+cells-find", "+cells-search"); got != 7 {
t.Errorf("sharedPrefixLen = %d, want 7", got)
}
if got := sharedPrefixLen("abc", "xyz"); got != 0 {
t.Errorf("sharedPrefixLen = %d, want 0", got)
}
}

View File

@@ -1,138 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// migratedCommonHelperPaths lists source-tree prefixes whose command validation
// has migrated to typed errs.* envelopes. On these paths, calls to common's
// legacy validation/save helpers are forbidden; callers must use the typed
// common replacements or construct an errs.* typed error directly.
var migratedCommonHelperPaths = []string{
"shortcuts/drive/",
}
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"
var legacyCommonHelperReplacements = map[string]string{
"FlagErrorf": "common.ValidationErrorf",
"MutuallyExclusive": "common.MutuallyExclusiveTyped",
"AtLeastOne": "common.AtLeastOneTyped",
"ExactlyOne": "common.ExactlyOneTyped",
"ValidatePageSize": "common.ValidatePageSizeTyped",
"ValidateChatID": "common.ValidateChatIDTyped",
"ValidateUserID": "common.ValidateUserIDTyped",
"ValidateSafePath": "common.ValidateSafePathTyped",
"RejectDangerousChars": "common.RejectDangerousCharsTyped",
"WrapInputStatError": "common.WrapInputStatErrorTyped",
"WrapSaveErrorByCategory": "common.WrapSaveErrorTyped",
"ResolveOpenIDs": "common.ResolveOpenIDsTyped",
"HandleApiResult": "runtime.CallAPITyped",
}
// CheckNoLegacyCommonHelperCall flags any reference to common's legacy helper
// APIs on migrated paths — direct calls and function-value references alike,
// so `f := common.FlagErrorf; f(...)` cannot slip past the guard. These
// helpers return legacy output envelopes or bare errors, so migrated domains
// should use their typed-aware replacements.
func CheckNoLegacyCommonHelperCall(path, src string) []Violation {
if !isMigratedCommonHelperPath(path) || strings.HasSuffix(path, "_test.go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
localNames, dotImported := resolveCommonNames(file)
var out []Violation
report := func(pos token.Pos, name, replacement string) {
out = append(out, Violation{
Rule: "no_legacy_common_helper_call",
Action: ActionReject,
File: path,
Line: fset.Position(pos).Line,
Message: "common." + name + " returns a legacy error shape and is forbidden on migrated paths",
Suggestion: "replace common." + name + " with " + replacement + " or a typed errs.* constructor",
})
}
// Pass 1: qualified references (common.X / alias.X). Record every
// selector field so the dot-import pass below never mistakes another
// package's same-named field for a common helper.
selFields := make(map[*ast.Ident]struct{})
ast.Inspect(file, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
selFields[sel.Sel] = struct{}{}
x, ok := sel.X.(*ast.Ident)
if !ok {
return true
}
if _, bound := localNames[x.Name]; !bound {
return true
}
if replacement, ok := legacyCommonHelperReplacements[sel.Sel.Name]; ok {
report(sel.Pos(), sel.Sel.Name, replacement)
}
return true
})
// Pass 2: unqualified references under a dot import.
if dotImported {
ast.Inspect(file, func(n ast.Node) bool {
ident, ok := n.(*ast.Ident)
if !ok {
return true
}
if _, isField := selFields[ident]; isField {
return true
}
if replacement, ok := legacyCommonHelperReplacements[ident.Name]; ok {
report(ident.Pos(), ident.Name, replacement)
}
return true
})
}
return out
}
func isMigratedCommonHelperPath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
for _, prefix := range migratedCommonHelperPaths {
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
return true
}
}
return false
}
func resolveCommonNames(file *ast.File) (map[string]struct{}, bool) {
names := make(map[string]struct{})
dotImported := false
for _, imp := range file.Imports {
if imp.Path == nil {
continue
}
p := strings.Trim(imp.Path.Value, "`\"")
if p != commonImportPath {
continue
}
switch {
case imp.Name == nil:
names["common"] = struct{}{}
case imp.Name.Name == ".":
dotImported = true
case imp.Name.Name == "_":
default:
names[imp.Name.Name] = struct{}{}
}
}
return names, dotImported
}

View File

@@ -877,123 +877,3 @@ func boom(runtime *common.RuntimeContext) error {
t.Errorf("test files must be skipped, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
helpers := []string{
"FlagErrorf",
"MutuallyExclusive",
"AtLeastOne",
"ExactlyOne",
"ValidatePageSize",
"ValidateChatID",
"ValidateUserID",
"ValidateSafePath",
"RejectDangerousChars",
"WrapInputStatError",
"WrapSaveErrorByCategory",
"ResolveOpenIDs",
"HandleApiResult",
}
for _, helper := range helpers {
t.Run(helper, func(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s, got %d: %+v", helper, len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "common."+helper) {
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
}
})
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package im
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/im/im_send.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must pass, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.ValidationErrorf("typed")
common.MutuallyExclusiveTyped(nil, "a", "b")
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
common.WrapSaveErrorTyped(nil)
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 0 {
t.Errorf("typed helpers must pass, got: %+v", v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
src := `package drive
import c "github.com/larksuite/cli/shortcuts/common"
func boom() {
c.FlagErrorf("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
src := `package drive
import . "github.com/larksuite/cli/shortcuts/common"
func boom() {
FlagErrorf("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
}
}
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/shortcuts/common"
func boom() error {
f := common.FlagErrorf
return f("legacy")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
}
}

View File

@@ -108,7 +108,6 @@ func ScanRepo(root string) ([]Violation, error) {
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
// Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.47",
"version": "1.0.46",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockCreate = common.Shortcut{
Service: "base",
Command: "+base-block-create",
Description: "Create a block",
Risk: "write",
Scopes: []string{"base:block:create"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums},
{Name: "name", Desc: "block name", Required: true},
{Name: "parent-id", Desc: "folder block id; when omitted, create at root"},
},
Tips: []string{
"Example: lark-cli base +base-block-create --base-token <base_token> --type folder --name \"Project Docs\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type table --name \"Tasks\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type docx --name \"Spec\" --parent-id <folder_block_id>",
"Example: lark-cli base +base-block-create --base-token <base_token> --type dashboard --name \"Metrics\"",
"Example: lark-cli base +base-block-create --base-token <base_token> --type workflow --name \"Approval Flow\"",
"Creates a folder, table, docx, dashboard, or workflow entry.",
"Do not pass null for --parent-id. Omit it to create at the root level.",
"Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockCreate(runtime)
},
DryRun: dryRunBaseBlockCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockCreate(runtime)
},
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockDelete = common.Shortcut{
Service: "base",
Command: "+base-block-delete",
Description: "Delete a block",
Risk: "high-risk-write",
Scopes: []string{"base:block:delete"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
},
Tips: []string{
"Example: lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Deletes the block identified by --block-id.",
"Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.",
"Different block types may have independent backing resources; deletion follows backend semantics.",
"Use +base-block-list first when you need to confirm the target block id.",
"If the user already explicitly confirmed this exact delete target, pass --yes without asking again.",
},
DryRun: dryRunBaseBlockDelete,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockDelete(runtime)
},
}

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockList = common.Shortcut{
Service: "base",
Command: "+base-block-list",
Description: "List blocks in a base",
Risk: "read",
Scopes: []string{"base:block:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
{Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums},
{Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"},
},
Tips: []string{
"Example: lark-cli base +base-block-list --base-token <base_token>",
"Example: lark-cli base +base-block-list --base-token <base_token> --type table",
"Example: lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`JQ crop: lark-cli base +base-block-list --base-token <base_token> | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`JQ crop docx: lark-cli base +base-block-list --base-token <base_token> --type docx | jq '.blocks[] | {name, docx_token}'`,
"Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.",
"For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.",
"For docx blocks, use the returned docx_token with docx commands.",
"For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.",
"This command returns the full backend list. It intentionally does not expose limit or offset.",
"Pass --type to list only one resource type.",
"Pass --parent-id to list only direct children of a folder.",
"Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.",
},
DryRun: dryRunBaseBlockList,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockList(runtime)
},
}

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockMove = common.Shortcut{
Service: "base",
Command: "+base-block-move",
Description: "Move a block",
Risk: "write",
Scopes: []string{"base:block:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
{Name: "parent-id", Desc: "target folder block id; when omitted, move to root"},
{Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"},
{Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"},
},
Tips: []string{
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"Example: lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
"Omit --parent-id to move the block to root; do not pass null.",
"--before-id and --after-id are mutually exclusive.",
"When moving a folder, its children remain under that folder.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockMove(runtime)
},
DryRun: dryRunBaseBlockMove,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockMove(runtime)
},
}

View File

@@ -1,179 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"}
func baseBlockIDFlag(required bool) common.Flag {
return common.Flag{Name: "block-id", Desc: "block id", Required: required}
}
func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/list").
Body(buildBaseBlockListBody(runtime)).
Set("base_token", runtime.Str("base-token"))
}
func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks").
Body(buildBaseBlockCreateBody(runtime)).
Set("base_token", runtime.Str("base-token"))
}
func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move").
Body(buildBaseBlockMoveBody(runtime)).
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename").
Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}).
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id").
Set("base_token", runtime.Str("base-token")).
Set("block_id", runtime.Str("block-id"))
}
func validateBaseBlockCreate(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return common.FlagErrorf("--type must not be blank")
}
return nil
}
func validateBaseBlockMove(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" {
return common.FlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return common.FlagErrorf("--name must not be blank")
}
return nil
}
func executeBaseBlockList(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime))
if err != nil {
return err
}
filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type")))
runtime.Out(data, nil)
return nil
}
func executeBaseBlockCreate(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "created": true}, nil)
return nil
}
func executeBaseBlockMove(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime))
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil)
return nil
}
func executeBaseBlockRename(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{
"name": strings.TrimSpace(runtime.Str("name")),
})
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil)
return nil
}
func executeBaseBlockDelete(runtime *common.RuntimeContext) error {
data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil)
return nil
}
func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
return body
}
func filterBaseBlockListData(data map[string]interface{}, blockType string) {
if blockType == "" {
return
}
blocks, ok := data["blocks"].([]interface{})
if !ok {
return
}
filtered := make([]interface{}, 0, len(blocks))
for _, block := range blocks {
blockMap, ok := block.(map[string]interface{})
if !ok || blockMap["type"] != blockType {
continue
}
filtered = append(filtered, block)
}
data["blocks"] = filtered
data["total"] = len(filtered)
}
func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"type": strings.TrimSpace(runtime.Str("type")),
"name": strings.TrimSpace(runtime.Str("name")),
}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
return body
}
func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{"parent_id": nil}
if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" {
body["parent_id"] = parentID
}
if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" {
body["before_id"] = beforeID
}
if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" {
body["after_id"] = afterID
}
return body
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
var BaseBaseBlockRename = common.Shortcut{
Service: "base",
Command: "+base-block-rename",
Description: "Rename a block",
Risk: "write",
Scopes: []string{"base:block:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
baseBlockIDFlag(true),
{Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true},
},
Tips: []string{
"Example: lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name \"New name\"",
"Renames the block identified by --block-id.",
"Block names must be unique in the base; use +base-block-list first when you need to check existing names.",
"Use +base-block-list first when you need to resolve the target block id from a visible name.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBaseBlockRename(runtime)
},
DryRun: dryRunBaseBlockRename,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeBaseBlockRename(runtime)
},
}

View File

@@ -32,29 +32,6 @@ func TestDryRunTableOps(t *testing.T) {
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
}
func TestDryRunBaseBlockOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
}
func TestDryRunFieldOps(t *testing.T) {
ctx := context.Background()

View File

@@ -411,108 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
return body
}
func TestBaseBlockExecuteShortcuts(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
listStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
},
"total": 2,
},
},
}
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
},
}
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
},
}
renameStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
},
}
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc"},
},
}
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
reg.Register(stub)
}
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
t.Fatalf("list err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
t.Fatalf("list stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
t.Fatalf("list body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
t.Fatalf("create err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("create stdout=%s", got)
}
createBody := decodeCapturedJSONBody(t, createStub)
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
t.Fatalf("create body=%#v", createBody)
}
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
t.Fatalf("move err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
t.Fatalf("move stdout=%s", got)
}
moveBody := decodeCapturedJSONBody(t, moveStub)
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
t.Fatalf("move body=%#v", moveBody)
}
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
t.Fatalf("rename err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
t.Fatalf("rename stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
t.Fatalf("rename body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("delete stdout=%s", got)
}
}
func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -133,7 +133,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
@@ -189,7 +188,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
@@ -243,30 +241,6 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
nil,
nil,
)
err := validateBaseBlockMove(runtime)
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
t.Fatalf("err=%v", err)
}
}
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("create err=%v", err)
}
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("rename err=%v", err)
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -754,79 +728,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "list",
shortcut: BaseBaseBlockList,
wantTips: []string{
"lark-cli base +base-block-list --base-token <base_token>",
"lark-cli base +base-block-list --base-token <base_token> --type table",
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`--type docx | jq '.blocks[] | {name, docx_token}'`,
"returned id is the table-id, dashboard-id, or workflow-id",
"For docx blocks, use the returned docx_token with docx commands.",
},
},
{
name: "create",
shortcut: BaseBaseBlockCreate,
wantTips: []string{
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
},
},
{
name: "move",
shortcut: BaseBaseBlockMove,
wantTips: []string{
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
},
},
{
name: "rename",
shortcut: BaseBaseBlockRename,
wantTips: []string{
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
},
},
{
name: "delete",
shortcut: BaseBaseBlockDelete,
wantTips: []string{
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Recursive folder deletion is not supported.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})

View File

@@ -8,11 +8,6 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all base shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
BaseBaseBlockList,
BaseBaseBlockCreate,
BaseBaseBlockMove,
BaseBaseBlockRename,
BaseBaseBlockDelete,
BaseTableList,
BaseTableGet,
BaseTableCreate,

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// ChatID is a typed Lark chat identifier with the "oc_" prefix.
// Satisfies common.Validatable.
type ChatID string
// ValidateValue checks the oc_ prefix and trims whitespace. Empty values are
// rejected here even though required-ness is enforced by the binder; this
// keeps the type safe to call as a standalone validator.
func (id ChatID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
s := strings.TrimSpace(string(id))
if s == "" {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "chat ID is required",
Hint: "pass --chat-id oc_xxx",
},
Param: flagName,
}
}
if !strings.HasPrefix(s, "oc_") {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "invalid chat ID format: expected oc_xxx",
},
Param: flagName,
}
}
return nil
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestChatID_ValidatePass(t *testing.T) {
id := ChatID("oc_abc123")
if err := id.ValidateValue(nil, "chat-id"); err != nil {
t.Errorf("oc_ prefix should pass, got: %v", err)
}
}
func TestChatID_ValidateReject(t *testing.T) {
tests := []struct {
name string
v string
}{
{"empty", ""},
{"wrong prefix", "ou_abc"},
{"random", "abc123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ChatID(tt.v).ValidateValue(nil, "chat-id")
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
}
if ve.Param != "chat-id" {
t.Errorf("Param = %q, want chat-id", ve.Param)
}
})
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// MediaInput is the tri-state media-field value used by im image/file/video/
// audio flags: URL, "img_xxx"/"file_xxx" key, or cwd-relative local path.
// URL and key forms bypass path safety checks; local paths go through the
// same SafePath rules. Does not emit absolute paths in hints (log safety).
type MediaInput string
// IsURL reports whether the value looks like an http(s) URL.
func (m MediaInput) IsURL() bool {
s := string(m)
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// IsMediaKey reports whether the value is an already-uploaded media key.
func (m MediaInput) IsMediaKey() bool {
s := string(m)
return strings.HasPrefix(s, "img_") || strings.HasPrefix(s, "file_")
}
func (m MediaInput) ValidateValue(rt *common.RuntimeContext, flagName string) error {
if string(m) == "" {
return nil
}
if m.IsURL() || m.IsMediaKey() {
return nil
}
return SafePath(m).ValidateValue(rt, flagName)
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"testing"
)
func TestMediaInput_AcceptURL(t *testing.T) {
for _, v := range []string{"https://example.com/x.png", "http://a.b/y"} {
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
t.Errorf("URL %q should pass: %v", v, err)
}
}
}
func TestMediaInput_AcceptKey(t *testing.T) {
for _, v := range []string{"img_abc123", "file_xyz"} {
if err := MediaInput(v).ValidateValue(nil, "image"); err != nil {
t.Errorf("key %q should pass: %v", v, err)
}
}
}
func TestMediaInput_RejectAbsolutePath(t *testing.T) {
if err := MediaInput("/etc/passwd").ValidateValue(nil, "image"); err == nil {
t.Fatal("absolute path must be rejected")
}
}
func TestMediaInput_AcceptRelativePath(t *testing.T) {
if err := MediaInput("./pic.png").ValidateValue(nil, "image"); err != nil {
t.Errorf("relative path should pass: %v", err)
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SafePath is a strict cwd-relative file path. Absolute paths and ".."
// segments are rejected. Does NOT emit absolute path back to stderr in any
// hint (log safety).
type SafePath string
func (p SafePath) ValidateValue(_ *common.RuntimeContext, flagName string) error {
s := string(p)
if s == "" {
return nil
}
if filepath.IsAbs(s) {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "path must be cwd-relative; absolute paths are rejected",
Hint: "use a relative path like ./file.txt",
},
Param: flagName,
}
}
// Check the RAW input for ".." segments before filepath.Clean collapses
// them — Clean turns "a/../b" into "b", which would otherwise hide a
// parent-traversal segment the user actually typed. Split on both
// separators so "\.." on Windows-style input is caught too.
for _, seg := range strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == '\\' }) {
if seg == ".." {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "path must not contain '..' segments",
},
Param: flagName,
}
}
}
clean := filepath.Clean(s)
if strings.HasPrefix(clean, "..") || strings.Contains(clean, "/../") || strings.Contains(clean, `\..\`) {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "path must not contain '..' segments",
},
Param: flagName,
}
}
return nil
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestSafePath_AcceptRelative(t *testing.T) {
for _, p := range []string{"local.txt", "./sub/dir/x", "a/b/c"} {
if err := SafePath(p).ValidateValue(nil, "file"); err != nil {
t.Errorf("%q should pass, got: %v", p, err)
}
}
}
func TestSafePath_RejectAbsolute(t *testing.T) {
err := SafePath("/etc/passwd").ValidateValue(nil, "file")
if err == nil {
t.Fatal("absolute path must be rejected")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "file" {
t.Errorf("wrong wrap: %v", err)
}
}
func TestSafePath_RejectDotDot(t *testing.T) {
err := SafePath("../leak").ValidateValue(nil, "file")
if err == nil {
t.Fatal("'..' segment must be rejected")
}
}
func TestSafePath_RejectMidPathDotDot(t *testing.T) {
// filepath.Clean collapses "a/../b" to "b"; the raw-segment scan must
// still reject it because the user literally typed a parent segment.
for _, p := range []string{"a/../b", "sub/../../etc", `win\..\x`} {
if err := SafePath(p).ValidateValue(nil, "file"); err == nil {
t.Errorf("%q should be rejected (raw .. segment)", p)
}
}
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"context"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// SpreadsheetRef is a Lark Sheets reference: either a raw "shtcn..." token
// or a feishu.cn/larksuite.com URL containing one. Normalize extracts the
// token from URLs; ValidateValue checks the final token shape. URL→token is
// a business-canonicalisation hint and is safe to emit to stderr.
type SpreadsheetRef string
func (s SpreadsheetRef) Normalize(_ context.Context, raw string) (SpreadsheetRef, []string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil, nil
}
if !strings.Contains(trimmed, "://") {
return SpreadsheetRef(trimmed), nil, nil
}
for _, seg := range strings.Split(trimmed, "/") {
if strings.HasPrefix(seg, "shtcn") {
// Strip any ?query or #fragment suffix so a URL like
// .../shtcnXXX?sheet=0#row=5 yields a clean token, not one
// polluted by trailing parameters that would later pass the
// prefix-only ValidateValue check.
token := seg
if i := strings.IndexAny(token, "?#"); i >= 0 {
token = token[:i]
}
return SpreadsheetRef(token), []string{"extracted spreadsheet token from URL"}, nil
}
}
return "", nil, &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "URL does not contain a recognisable spreadsheet token",
},
Param: "",
}
}
func (s SpreadsheetRef) ValidateValue(_ *common.RuntimeContext, flagName string) error {
v := strings.TrimSpace(string(s))
if v == "" {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "spreadsheet reference is required",
},
Param: flagName,
}
}
if !strings.HasPrefix(v, "shtcn") {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "spreadsheet token must start with 'shtcn'",
},
Param: flagName,
}
}
return nil
}

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"context"
"testing"
)
func TestSpreadsheetRef_NormalizeFromURL(t *testing.T) {
url := "https://feishu.cn/sheets/shtcnAbCdEf01234567"
v, hints, err := SpreadsheetRef(url).Normalize(context.Background(), url)
if err != nil {
t.Fatalf("Normalize error: %v", err)
}
if string(v) != "shtcnAbCdEf01234567" {
t.Errorf("expected extracted token, got %q", v)
}
if len(hints) == 0 {
t.Errorf("expected at least one hint about URL extraction")
}
}
func TestSpreadsheetRef_NormalizeStripsQueryAndFragment(t *testing.T) {
for _, url := range []string{
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0",
"https://feishu.cn/sheets/shtcnAbCdEf01234567#row=5",
"https://feishu.cn/sheets/shtcnAbCdEf01234567?sheet=0#row=5",
} {
v, _, err := SpreadsheetRef(url).Normalize(context.Background(), url)
if err != nil {
t.Fatalf("Normalize(%q) error: %v", url, err)
}
if string(v) != "shtcnAbCdEf01234567" {
t.Errorf("Normalize(%q): expected clean token, got %q", url, v)
}
}
}
func TestSpreadsheetRef_NormalizePassThroughToken(t *testing.T) {
v, hints, err := SpreadsheetRef("shtcnXyz").Normalize(context.Background(), "shtcnXyz")
if err != nil {
t.Fatalf("Normalize error: %v", err)
}
if string(v) != "shtcnXyz" {
t.Errorf("raw token should pass through, got %q", v)
}
if len(hints) != 0 {
t.Errorf("no hint expected for raw token, got %v", hints)
}
}
func TestSpreadsheetRef_ValidateValueRejectsEmpty(t *testing.T) {
if err := SpreadsheetRef("").ValidateValue(nil, "spreadsheet"); err == nil {
t.Fatal("empty value should fail validation")
}
}
func TestSpreadsheetRef_ValidateValueAcceptsToken(t *testing.T) {
if err := SpreadsheetRef("shtcnAbCd").ValidateValue(nil, "spreadsheet"); err != nil {
t.Errorf("token should pass: %v", err)
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// UserOpenID is a typed Lark user identifier with the "ou_" prefix.
type UserOpenID string
func (id UserOpenID) ValidateValue(_ *common.RuntimeContext, flagName string) error {
s := strings.TrimSpace(string(id))
if s == "" {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "user open_id is required",
Hint: "pass --user-id ou_xxx",
},
Param: flagName,
}
}
if !strings.HasPrefix(s, "ou_") {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "invalid user open_id format: expected ou_xxx",
},
Param: flagName,
}
}
return nil
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package argstype
import (
"errors"
"testing"
"github.com/larksuite/cli/errs"
)
func TestUserOpenID_ValidatePass(t *testing.T) {
if err := UserOpenID("ou_abc").ValidateValue(nil, "user-id"); err != nil {
t.Errorf("ou_ prefix should pass, got: %v", err)
}
}
func TestUserOpenID_ValidateReject(t *testing.T) {
for _, v := range []string{"", "oc_abc", "abc"} {
err := UserOpenID(v).ValidateValue(nil, "user-id")
if err == nil {
t.Errorf("expected error for %q", v)
continue
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "user-id" {
t.Errorf("wrong wrap for %q: %v", v, err)
}
}
}

795
shortcuts/common/binder.go Normal file
View File

@@ -0,0 +1,795 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
// fieldSpec is the binder's intermediate representation of one Args field.
// It captures both reflection metadata and the parsed tag values so later
// binder stages don't repeat the parsing work.
type fieldSpec struct {
GoFieldName string
FlagName string
Description string
DefaultValue string
EnumValues []string
Input []string
Required bool
Hidden bool
NoSplit bool
IsOneOfBkt bool
IsGroup bool
IsPtr bool
FieldType reflect.Type
StructType reflect.Type
}
// walkArgs reflects an Args struct (must be *T where T is struct) and
// returns one fieldSpec per top-level field. Duplicate flag tags inside
// the same Args struct panic at Mount time — cross-shortcut duplicates are
// not checked (cobra's own per-command Add check covers that).
func walkArgs(t reflect.Type) ([]fieldSpec, error) {
if t.Kind() != reflect.Ptr || t.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("Args must be *struct, got %s", t)
}
st := t.Elem()
specs := make([]fieldSpec, 0, st.NumField())
seen := map[string]string{}
for i := 0; i < st.NumField(); i++ {
f := st.Field(i)
spec, err := parseFieldSpec(f)
if err != nil {
return nil, err
}
if spec.FlagName != "" {
if owner, dup := seen[spec.FlagName]; dup {
panic(fmt.Sprintf("duplicate flag tag %q in Args struct: fields %s and %s",
spec.FlagName, owner, f.Name))
}
seen[spec.FlagName] = f.Name
}
specs = append(specs, spec)
}
return specs, nil
}
// parseFieldSpec extracts the relevant tag values from one struct field.
// Sub-struct (OneOf bucket / group) detection is delegated to caller stages;
// here we only set flags about the field shape.
func parseFieldSpec(f reflect.StructField) (fieldSpec, error) {
spec := fieldSpec{
GoFieldName: f.Name,
FlagName: f.Tag.Get("flag"),
Description: f.Tag.Get("desc"),
DefaultValue: f.Tag.Get("default"),
}
if enum := f.Tag.Get("enum"); enum != "" {
spec.EnumValues = strings.Split(enum, ",")
}
if _, has := f.Tag.Lookup("required"); has {
spec.Required = true
}
if input := f.Tag.Get("input"); input != "" {
for _, src := range strings.Split(input, ",") {
switch src = strings.TrimSpace(src); src {
case "":
// tolerate stray commas
case File, Stdin:
spec.Input = append(spec.Input, src)
default:
return spec, fmt.Errorf("field %s: unknown input source %q (allowed: %q, %q)", f.Name, src, File, Stdin)
}
}
}
if _, has := f.Tag.Lookup("hidden"); has {
spec.Hidden = true
}
switch split := strings.TrimSpace(f.Tag.Get("split")); split {
case "", "comma":
// default: cobra StringSlice (comma-separated, also repeatable)
case "none":
spec.NoSplit = true // cobra StringArray: repeatable, no comma split
default:
return spec, fmt.Errorf("field %s: unknown split mode %q (allowed: comma, none)", f.Name, split)
}
ft := f.Type
spec.FieldType = ft
if ft.Kind() == reflect.Ptr {
spec.IsPtr = true
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
spec.StructType = ft
ptr := reflect.PointerTo(ft)
marker := reflect.TypeOf((*OneOfMarker)(nil)).Elem()
if ft.Implements(marker) || ptr.Implements(marker) {
spec.IsOneOfBkt = true
} else {
spec.IsGroup = true
}
return spec, nil
}
// Leaf field validation. Multi-value flags are []string (cobra
// StringSlice / StringArray); any other slice element type is unsupported.
// enum / input only make sense on plain string leaves (string or a
// string-alias like ChatID); split only on []string. Reject mismatches at
// Mount time instead of silently skipping or panicking at runtime.
isStringSlice := ft.Kind() == reflect.Slice && ft.Elem().Kind() == reflect.String
if ft.Kind() == reflect.Slice && !isStringSlice {
return spec, fmt.Errorf("field %s: only []string slices are supported, got %s", f.Name, ft)
}
if spec.NoSplit && !isStringSlice {
return spec, fmt.Errorf("field %s: split tag is only supported on []string fields", f.Name)
}
if ft.Kind() != reflect.String {
if len(spec.EnumValues) > 0 {
return spec, fmt.Errorf("field %s: enum tag is only supported on string fields", f.Name)
}
if len(spec.Input) > 0 {
return spec, fmt.Errorf("field %s: input tag is only supported on string fields", f.Name)
}
}
return spec, nil
}
// registerFlags registers cobra flags for the given specs. Sub-struct fields
// (OneOf bucket / group) recurse into their inner fields.
func registerFlags(cmd *cobra.Command, specs []fieldSpec) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := registerFlags(cmd, inner); err != nil {
return err
}
continue
}
if err := registerLeaf(cmd, s, s.FieldType); err != nil {
return err
}
}
return nil
}
// registerLeaf registers a single primitive flag based on the underlying type.
func registerLeaf(cmd *cobra.Command, s fieldSpec, t reflect.Type) error {
if s.FlagName == "" {
return nil
}
if t.Kind() == reflect.Ptr {
t = t.Elem()
}
switch t.Kind() {
case reflect.Bool:
def := s.DefaultValue == "true"
cmd.Flags().Bool(s.FlagName, def, s.Description)
case reflect.Int, reflect.Int64:
def := 0
if s.DefaultValue != "" {
def, _ = strconv.Atoi(s.DefaultValue)
}
cmd.Flags().Int(s.FlagName, def, s.Description)
case reflect.Slice:
// []string (validated at parse time). NoSplit → StringArray
// (repeatable, literal); default → StringSlice (comma-separated).
if s.NoSplit {
cmd.Flags().StringArray(s.FlagName, nil, s.Description)
} else {
cmd.Flags().StringSlice(s.FlagName, nil, s.Description)
}
default:
cmd.Flags().String(s.FlagName, s.DefaultValue, s.Description)
}
if s.Required {
_ = cmd.MarkFlagRequired(s.FlagName)
}
if s.Hidden {
_ = cmd.Flags().MarkHidden(s.FlagName)
}
if len(s.EnumValues) > 0 {
vals := s.EnumValues
cmdutil.RegisterFlagCompletion(cmd, s.FlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return vals, cobra.ShellCompDirectiveNoFileComp
})
}
return nil
}
// resolveTypedInputs applies @file / stdin resolution to every leaf flag that
// declared an `input:"file,stdin"` tag, recursing into OneOf buckets and groups
// (cobra flags are flat, so a nested variant's flag resolves the same way). It
// runs before bindFlags so the file/stdin content is read back into the cobra
// flag and then bound into the Args struct. This is the typed counterpart of
// runShortcut's resolveInputFlags, which only sees the legacy shell's (empty)
// Flags slice — both ultimately call resolveInputForFlag.
func resolveTypedInputs(rctx *RuntimeContext, specs []fieldSpec) error {
stdinUsed := false
return resolveTypedInputsRec(rctx, specs, &stdinUsed)
}
func resolveTypedInputsRec(rctx *RuntimeContext, specs []fieldSpec, stdinUsed *bool) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := resolveTypedInputsRec(rctx, inner, stdinUsed); err != nil {
return err
}
continue
}
if s.FlagName == "" || len(s.Input) == 0 {
continue
}
if err := resolveInputForFlag(rctx, s.FlagName, s.Input, stdinUsed); err != nil {
return err
}
}
return nil
}
// bindFlags writes cobra-parsed values back into the Args struct. argsVal
// is a reflect.Value of the struct (not pointer).
func bindFlags(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
continue
}
if s.FlagName == "" {
continue
}
if err := bindLeaf(cmd, argsVal, s); err != nil {
return err
}
}
return nil
}
func bindLeaf(cmd *cobra.Command, argsVal reflect.Value, s fieldSpec) error {
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.CanSet() {
return nil
}
// Pointer leaves preserve "nil = not given" semantics: only allocate when
// the user explicitly set the flag. This mirrors the OneOf bucket
// convention (`Chat *ChatID` — nil means the variant wasn't selected) and
// lets typed shortcuts express tri-state bool / int / string flags using
// plain Go pointers instead of a separate Maybe[T] wrapper type.
if s.IsPtr && !cmd.Flags().Changed(s.FlagName) {
return nil
}
leafType := s.FieldType
if leafType.Kind() == reflect.Ptr {
leafType = leafType.Elem()
}
switch leafType.Kind() {
case reflect.Bool:
v, _ := cmd.Flags().GetBool(s.FlagName)
setLeaf(fv, reflect.ValueOf(v))
case reflect.Int, reflect.Int64:
v, _ := cmd.Flags().GetInt(s.FlagName)
setLeaf(fv, reflect.ValueOf(int64(v)).Convert(leafType))
case reflect.Slice:
var vals []string
if s.NoSplit {
vals, _ = cmd.Flags().GetStringArray(s.FlagName)
} else {
vals, _ = cmd.Flags().GetStringSlice(s.FlagName)
}
setLeaf(fv, stringsToSlice(vals, leafType))
default:
v, _ := cmd.Flags().GetString(s.FlagName)
setLeaf(fv, reflect.ValueOf(v).Convert(leafType))
}
return nil
}
// stringsToSlice builds a slice value of type t (kind Slice with string-kinded
// elements) from raw strings, element-by-element via SetString. This handles a
// plain []string, a named slice type (type IDs []string), AND a named element
// type (type ID string → []ID) — reflect.Convert would panic on the last case
// because []string is not convertible to []ID.
func stringsToSlice(vals []string, t reflect.Type) reflect.Value {
out := reflect.MakeSlice(t, len(vals), len(vals))
for i, v := range vals {
out.Index(i).SetString(v)
}
return out
}
func setLeaf(dst reflect.Value, src reflect.Value) {
if dst.Kind() == reflect.Ptr {
ptr := reflect.New(dst.Type().Elem())
ptr.Elem().Set(src.Convert(dst.Type().Elem()))
dst.Set(ptr)
return
}
dst.Set(src.Convert(dst.Type()))
}
// bindBuckets allocates and populates OneOf bucket / group sub-struct fields
// from cobra flag state. The shared bindFlags() above only writes top-level
// leaves; this function is the framework's recursion into nested Args structs
// so future typed shortcuts don't each have to ship a bespoke binder helper.
//
// Conventions:
// - Pointer-leaf in a bucket (e.g. *string, *argstype.ChatID): set iff
// cobra reports the flag was explicitly provided. nil means "variant not
// selected" — the framework's runFrameworkRules and runValidateValue
// both honor this nil/non-nil split.
// - Non-pointer leaf in a group (e.g. a typed-primitive field inside a
// paired group struct): always copy the cobra flag value back. Empty
// string is a valid "not provided" sentinel for group completeness checks.
// - Pointer-to-group / pointer-to-bucket (a nested group/bucket pointer
// inside an outer OneOf bucket): allocate iff at least one inner flag was
// Changed, then recurse to bind the inner fields.
func bindBuckets(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if !s.IsOneOfBkt {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
target := fv
if target.Kind() == reflect.Ptr {
if target.IsNil() {
target.Set(reflect.New(s.StructType))
}
target = target.Elem()
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := bindBucketInner(cmd, target, inner); err != nil {
return err
}
}
return nil
}
// bindGroups is the top-level counterpart to bindBuckets for IsGroup fields
// (regular nested structs without an OneOf() marker). A group's inner flags
// are registered and validated for completeness, but bindFlags above skips
// the field; this function fills the binding gap so an Args struct can place
// a group directly at the top level (not just nested inside an OneOf bucket).
//
// Conventions mirror bindBuckets / bindBucketInner:
// - Value-type group: always populated; inner fields receive cobra flag
// values (including defaults).
// - Pointer-type group: allocated iff at least one inner flag was Changed,
// so a nil group still signals "user did not engage this group at all".
func bindGroups(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if !s.IsGroup {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if fv.Kind() == reflect.Ptr {
anyChanged := false
for _, g := range inner {
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
anyChanged = true
break
}
}
if !anyChanged {
continue
}
if fv.IsNil() {
fv.Set(reflect.New(s.StructType))
}
if err := bindBucketInner(cmd, fv.Elem(), inner); err != nil {
return err
}
continue
}
// Value-type group: populate inner fields directly.
if err := bindBucketInner(cmd, fv, inner); err != nil {
return err
}
}
return nil
}
func bindBucketInner(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
grandSpecs, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
anyChanged := false
for _, g := range grandSpecs {
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
anyChanged = true
break
}
}
if !anyChanged {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
target := fv
if fv.Kind() == reflect.Ptr {
if fv.IsNil() {
fv.Set(reflect.New(s.StructType))
}
target = fv.Elem()
}
if err := bindBucketInner(cmd, target, grandSpecs); err != nil {
return err
}
continue
}
if s.FlagName == "" {
continue
}
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() || !fv.CanSet() {
continue
}
if s.IsPtr {
if !cmd.Flags().Changed(s.FlagName) {
continue
}
elemType := fv.Type().Elem()
ptr := reflect.New(elemType)
ptr.Elem().Set(bucketLeafValue(cmd, s.FlagName, elemType, s.NoSplit))
fv.Set(ptr)
continue
}
fv.Set(bucketLeafValue(cmd, s.FlagName, fv.Type(), s.NoSplit))
}
return nil
}
// bucketLeafValue reads a single cobra flag and returns it as a reflect.Value
// convertible to targetType. It dispatches on the underlying kind so nested
// bucket/group leaves typed as bool or int bind correctly instead of being
// force-read through GetString (which would panic on reflect conversion of a
// string into a numeric/bool type).
func bucketLeafValue(cmd *cobra.Command, flagName string, targetType reflect.Type, noSplit bool) reflect.Value {
kind := targetType.Kind()
if kind == reflect.Ptr {
kind = targetType.Elem().Kind()
}
switch kind {
case reflect.Bool:
v, _ := cmd.Flags().GetBool(flagName)
return reflect.ValueOf(v).Convert(targetType)
case reflect.Int, reflect.Int64:
v, _ := cmd.Flags().GetInt(flagName)
return reflect.ValueOf(int64(v)).Convert(targetType)
case reflect.Slice:
var vals []string
if noSplit {
vals, _ = cmd.Flags().GetStringArray(flagName)
} else {
vals, _ = cmd.Flags().GetStringSlice(flagName)
}
return stringsToSlice(vals, targetType)
default:
v, _ := cmd.Flags().GetString(flagName)
return reflect.ValueOf(v).Convert(targetType)
}
}
// runNormalize invokes the Normalize method (via reflection) on every field
// whose type implements Normalizable[T]. The canonical value is written back.
// Plan's static type assertion can't work because Normalizable is generic —
// the method's return type is the typed T, not any — so we dispatch by
// reflection on method shape.
//
// Local-path normalization hints are dropped at the primitive layer; only
// non-path hints reach stderr (log safety).
func runNormalize(ctx context.Context, rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
ctxType := reflect.TypeOf((*context.Context)(nil)).Elem()
for _, s := range specs {
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() {
continue
}
m := fv.MethodByName("Normalize")
if !m.IsValid() {
continue
}
mt := m.Type()
if mt.NumIn() != 2 || mt.NumOut() != 3 {
continue
}
if !ctxType.AssignableTo(mt.In(0)) || mt.In(1).Kind() != reflect.String {
continue
}
raw := asString(fv)
rets := m.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(raw)})
if errRet := rets[2]; !errRet.IsNil() {
return errRet.Interface().(error)
}
canon := rets[0]
if fv.CanSet() && canon.Type().AssignableTo(fv.Type()) {
fv.Set(canon)
}
hints, _ := rets[1].Interface().([]string)
if len(hints) > 0 && rt != nil && rt.Cmd != nil {
for _, h := range hints {
_, _ = rt.Cmd.ErrOrStderr().Write([]byte(h + "\n"))
}
}
}
return nil
}
func asString(fv reflect.Value) string {
if fv.Kind() == reflect.String {
return fv.String()
}
if fv.Kind() == reflect.Ptr && !fv.IsNil() {
return asString(fv.Elem())
}
return ""
}
// runValidateValue calls ValidateValue on every Validatable field, recursing
// into OneOf bucket / group sub-structs so typed-primitive leaves inside
// nested Args structs (e.g. a typed ID primitive inside a OneOf bucket) still
// get their format check. Returns the first error to keep error envelopes
// deterministic.
func runValidateValue(rt *RuntimeContext, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
fv := argsVal.FieldByName(s.GoFieldName)
if !fv.IsValid() {
continue
}
if s.IsOneOfBkt || s.IsGroup {
structVal := fv
if structVal.Kind() == reflect.Ptr {
if structVal.IsNil() {
continue
}
structVal = structVal.Elem()
}
// Some buckets/groups implement Validatable themselves (e.g. a
// raw-JSON variant that checks JSON validity in its ValidateValue).
// Call the struct-level check BEFORE recursing into inner fields so
// the cross-field rule fires even when none of the inner leaves are
// individually Validatable.
if fv.CanInterface() {
if val, ok := fv.Interface().(Validatable); ok {
if err := val.ValidateValue(rt, s.FlagName); err != nil {
return err
}
}
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := runValidateValue(rt, structVal, inner); err != nil {
return err
}
continue
}
// Leaf field. Skip nil pointers (variant not selected).
if fv.Kind() == reflect.Ptr && fv.IsNil() {
continue
}
if !fv.CanInterface() {
continue
}
v := fv.Interface()
if val, ok := v.(Validatable); ok {
if err := val.ValidateValue(rt, s.FlagName); err != nil {
return err
}
continue
}
// Pointer leaf: dereference and re-check (for value-receiver
// ValidateValue methods on the pointee type).
if fv.Kind() == reflect.Ptr {
if val, ok := fv.Elem().Interface().(Validatable); ok {
if err := val.ValidateValue(rt, s.FlagName); err != nil {
return err
}
}
}
}
return nil
}
// runFrameworkRules enforces OneOf / group / required / enum invariants and
// returns a *errs.ValidationError on the first violation. Each rule's
// stderr-facing param is the Args field name (not the inner struct type name),
// so OneOf bucket errors mention the user-visible field (e.g. "Target") rather
// than the implementation-detail type name behind it.
//
// Recurses into OneOf bucket sub-structs so a nested group inside a bucket
// still gets its checkGroup fire automatically.
func runFrameworkRules(cmd *cobra.Command, argsVal reflect.Value, specs []fieldSpec) error {
for _, s := range specs {
fv := argsVal.FieldByName(s.GoFieldName)
switch {
case s.IsOneOfBkt:
if err := checkOneOf(cmd, fv, s); err != nil {
return err
}
structVal := fv
if structVal.Kind() == reflect.Ptr {
if structVal.IsNil() {
continue
}
structVal = structVal.Elem()
}
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
if err := runFrameworkRules(cmd, structVal, inner); err != nil {
return err
}
case s.IsGroup:
if err := checkGroup(cmd, fv, s); err != nil {
return err
}
default:
if err := checkEnumAndRequired(cmd, fv, s); err != nil {
return err
}
}
}
return nil
}
// checkOneOf counts how many variants the user attempted inside the OneOf
// bucket; exactly one must be attempted. A variant counts as "attempted" if:
// - it's a simple pointer leaf (e.g. *string, *ChatID) and its own flag was
// explicitly provided, or
// - it's a nested group / bucket and ANY of its inner flags was explicitly
// provided. No "trigger" field is required — supplying any flag of a
// group is enough to mark the variant as attempted, and a follow-up
// checkGroup catches the partial-fill case with shortcut_group_incomplete.
func checkOneOf(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
var triggered []string
for _, child := range inner {
// Simple pointer leaf variant: its own flag is the signal.
if child.IsPtr && !child.IsOneOfBkt && !child.IsGroup {
if child.FlagName != "" && cmd.Flags().Changed(child.FlagName) {
triggered = append(triggered, "--"+child.FlagName)
}
continue
}
// Nested group / bucket variant: any inner flag Changed counts.
if child.IsGroup || child.IsOneOfBkt {
grand, _ := walkArgs(reflect.PointerTo(child.StructType))
for _, g := range grand {
if g.FlagName != "" && cmd.Flags().Changed(g.FlagName) {
triggered = append(triggered, "--"+g.FlagName)
break
}
}
}
}
switch len(triggered) {
case 0:
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeShortcutOneOfMissing,
Message: "exactly one " + s.GoFieldName + " variant must be provided",
},
Param: s.GoFieldName,
}
case 1:
return nil
default:
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeShortcutOneOfMultiple,
Message: "choose only one of " + strings.Join(triggered, ", "),
},
Param: s.GoFieldName,
}
}
}
// checkGroup ensures all fields of a group sub-struct were provided when
// the group's trigger (or first field) was set.
func checkGroup(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
inner, err := walkArgs(reflect.PointerTo(s.StructType))
if err != nil {
return err
}
anySet := false
var missing []string
for _, child := range inner {
if child.FlagName == "" {
continue
}
if cmd.Flags().Changed(child.FlagName) {
anySet = true
continue
}
// Flags with a default value are never "missing" — the default is a
// valid implicit value (e.g. an enum flag that defaults to a value).
// Only flags without defaults need explicit user input when the
// group is partially populated.
if child.DefaultValue != "" {
continue
}
missing = append(missing, "--"+child.FlagName)
}
if anySet && len(missing) > 0 {
// Group errors use the inner struct TYPE name (the group struct's own
// name), not the Args field name. This matches the spec's
// "shortcut_group_incomplete" envelope contract — callers identify
// the *kind* of group that is incomplete, which is the type name.
// OneOf bucket errors use the field name instead (see checkOneOf).
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeShortcutGroupIncomplete,
Message: s.StructType.Name() + " requires " + strings.Join(missing, ", "),
},
Param: s.StructType.Name(),
}
}
return nil
}
// checkEnumAndRequired enforces enum membership. Required-presence is already
// enforced at cobra level via MarkFlagRequired, so this only adds the enum
// check.
func checkEnumAndRequired(cmd *cobra.Command, _ reflect.Value, s fieldSpec) error {
if len(s.EnumValues) == 0 {
return nil
}
v, _ := cmd.Flags().GetString(s.FlagName)
if v == "" && s.DefaultValue == "" {
return nil
}
for _, allowed := range s.EnumValues {
if v == allowed {
return nil
}
}
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: "--" + s.FlagName + ": value must be one of " + strings.Join(s.EnumValues, "|"),
},
Param: s.FlagName,
}
}

View File

@@ -0,0 +1,294 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"context"
"errors"
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
)
// --- top-level pointer leaf: nil = not given (mirrors OneOf bucket convention) ---
type ptrLeafArgs struct {
Notify *bool `flag:"notify"`
Limit *int `flag:"limit"`
Name *string `flag:"name"`
}
func TestBindLeaf_PtrNilWhenAbsent(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &ptrLeafArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Notify != nil || out.Limit != nil || out.Name != nil {
t.Errorf("expected all pointer leaves nil when no flag given; got Notify=%v Limit=%v Name=%v",
out.Notify, out.Limit, out.Name)
}
}
// TestBindLeaf_PtrSetWhenChanged covers the tri-state contract that previously
// required Maybe[T]: a pointer leaf set to its zero value (e.g. --notify=false)
// MUST come back non-nil so business code can tell it apart from "not given".
func TestBindLeaf_PtrSetWhenChanged(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&ptrLeafArgs{}))
_ = registerFlags(cmd, specs)
if err := cmd.ParseFlags([]string{"--notify=false", "--limit", "5", "--name", "alice"}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &ptrLeafArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Notify == nil || *out.Notify != false {
t.Errorf("Notify = %v, want non-nil false (tri-state: zero value is still 'set')", out.Notify)
}
if out.Limit == nil || *out.Limit != 5 {
t.Errorf("Limit = %v, want non-nil 5", out.Limit)
}
if out.Name == nil || *out.Name != "alice" {
t.Errorf("Name = %v, want non-nil alice", out.Name)
}
}
// --- OneOf bucket binding: bindBuckets / bindBucketInner / bucketLeafValue ----
type bcLeafBucket struct {
S *string `flag:"lb-s"`
I *int `flag:"lb-i"`
B *bool `flag:"lb-b"`
Plain string `flag:"lb-plain"` // non-pointer leaf exercises the value branch
}
func (bcLeafBucket) OneOf() {}
type bcValueBucketArgs struct {
Sel bcLeafBucket
}
func TestBindBuckets_ValueBucketTypedLeaves(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bcValueBucketArgs{}))
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags([]string{"--lb-s", "hi", "--lb-i", "7", "--lb-b", "--lb-plain", "p"}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &bcValueBucketArgs{}
val := reflect.ValueOf(out).Elem()
if err := bindFlags(cmd, val, specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if err := bindBuckets(cmd, val, specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Sel.S == nil || *out.Sel.S != "hi" {
t.Errorf("Sel.S = %v, want hi", out.Sel.S)
}
if out.Sel.I == nil || *out.Sel.I != 7 {
t.Errorf("Sel.I = %v, want 7", out.Sel.I)
}
if out.Sel.B == nil || *out.Sel.B != true {
t.Errorf("Sel.B = %v, want true", out.Sel.B)
}
if out.Sel.Plain != "p" {
t.Errorf("Sel.Plain = %q, want p", out.Sel.Plain)
}
}
type bcPtrBucketArgs struct {
Sel *bcLeafBucket
}
func TestBindBuckets_PointerBucketAllocates(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bcPtrBucketArgs{}))
_ = registerFlags(cmd, specs)
if err := cmd.ParseFlags([]string{"--lb-s", "x"}); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
out := &bcPtrBucketArgs{}
val := reflect.ValueOf(out).Elem()
if err := bindBuckets(cmd, val, specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Sel == nil {
t.Fatal("Sel pointer was not allocated")
}
if out.Sel.S == nil || *out.Sel.S != "x" {
t.Errorf("Sel.S = %v, want x", out.Sel.S)
}
}
// --- runNormalize / asString --------------------------------------------------
type bcNormField string
func (n bcNormField) Normalize(_ context.Context, raw string) (bcNormField, []string, error) {
if raw == "boom" {
return "", nil, errors.New("normalize failed")
}
return bcNormField("c:" + raw), []string{"hint: " + raw}, nil
}
type bcNormArgs struct {
Token bcNormField `flag:"token"`
TokenPtr *bcNormField `flag:"token-ptr"`
}
func TestRunNormalize_CanonicalizesAndEmitsHints(t *testing.T) {
var buf bytes.Buffer
cmd := &cobra.Command{Use: "test"}
cmd.SetErr(&buf)
rt := &RuntimeContext{Cmd: cmd}
ptr := bcNormField("xy")
args := &bcNormArgs{Token: "raw", TokenPtr: &ptr}
specs, _ := walkArgs(reflect.TypeOf(args))
if err := runNormalize(context.Background(), rt, reflect.ValueOf(args).Elem(), specs); err != nil {
t.Fatalf("runNormalize: %v", err)
}
if args.Token != "c:raw" {
t.Errorf("Token = %q, want c:raw", args.Token)
}
if got := buf.String(); got == "" || !bytes.Contains([]byte(got), []byte("hint: raw")) {
t.Errorf("stderr = %q, want it to contain the normalize hint", got)
}
}
type bcBadNormArgs struct {
Token bcNormField `flag:"token"`
}
func TestRunNormalize_PropagatesError(t *testing.T) {
args := &bcBadNormArgs{Token: "boom"}
specs, _ := walkArgs(reflect.TypeOf(args))
err := runNormalize(context.Background(), nil, reflect.ValueOf(args).Elem(), specs)
if err == nil {
t.Fatal("expected error from Normalize")
}
}
// --- checkGroup (via runFrameworkRules) ---------------------------------------
type bcGroupBody struct {
A string `flag:"g-a"`
B string `flag:"g-b"`
C string `flag:"g-c" default:"x"` // default means never "missing"
}
type bcGroupArgs struct {
Grp bcGroupBody
}
func TestCheckGroup_IncompleteReportsMissing(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
_ = registerFlags(cmd, specs)
// Only --g-a set: B is missing (no default), C has a default so it is fine.
_ = cmd.ParseFlags([]string{"--g-a", "v"})
out := &bcGroupArgs{}
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected group_incomplete error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
t.Errorf("Subtype = %q, want group_incomplete", ve.Subtype)
}
}
func TestCheckGroup_CompleteAndUntouched(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&bcGroupArgs{}))
// Complete: A and B provided.
cmd1 := &cobra.Command{Use: "t1"}
_ = registerFlags(cmd1, specs)
_ = cmd1.ParseFlags([]string{"--g-a", "1", "--g-b", "2"})
if err := runFrameworkRules(cmd1, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
t.Errorf("complete group should pass, got %v", err)
}
// Untouched: nothing set → group rule does not fire.
cmd2 := &cobra.Command{Use: "t2"}
_ = registerFlags(cmd2, specs)
_ = cmd2.ParseFlags(nil)
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcGroupArgs{}).Elem(), specs); err != nil {
t.Errorf("untouched group should pass, got %v", err)
}
}
// --- checkEnumAndRequired (via runFrameworkRules) -----------------------------
type bcEnumArgs struct {
Mode string `flag:"mode" enum:"a,b,c"`
}
func TestCheckEnum(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&bcEnumArgs{}))
// Invalid value.
cmd1 := &cobra.Command{Use: "t1"}
_ = registerFlags(cmd1, specs)
_ = cmd1.ParseFlags([]string{"--mode", "z"})
err := runFrameworkRules(cmd1, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs)
if err == nil {
t.Fatal("expected invalid_argument error for bad enum value")
}
if ve := mustValidationError(t, err); ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want invalid_argument", ve.Subtype)
}
// Valid value.
cmd2 := &cobra.Command{Use: "t2"}
_ = registerFlags(cmd2, specs)
_ = cmd2.ParseFlags([]string{"--mode", "b"})
if err := runFrameworkRules(cmd2, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
t.Errorf("valid enum value should pass, got %v", err)
}
// Empty (no default) → enum check skipped.
cmd3 := &cobra.Command{Use: "t3"}
_ = registerFlags(cmd3, specs)
_ = cmd3.ParseFlags(nil)
if err := runFrameworkRules(cmd3, reflect.ValueOf(&bcEnumArgs{}).Elem(), specs); err != nil {
t.Errorf("empty enum value should pass, got %v", err)
}
}
// --- runValidateValue recursion into a group sub-struct -----------------------
type bcValGroup struct {
ID dummyValidatable `flag:"vg-id"`
}
type bcValGroupArgs struct {
Grp bcValGroup
}
func TestRunValidateValue_RecursesIntoGroup(t *testing.T) {
args := &bcValGroupArgs{}
specs, _ := walkArgs(reflect.TypeOf(args))
rt := &RuntimeContext{}
if err := runValidateValue(rt, reflect.ValueOf(args).Elem(), specs); err != nil {
t.Errorf("runValidateValue into group: %v", err)
}
}

View File

@@ -0,0 +1,237 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
_ "github.com/larksuite/cli/internal/vfs/localfileio"
)
// newTypedInputRuntime registers the given specs on a fresh cobra command,
// parses argv, and returns a RuntimeContext wired with a fake stdin — the
// typed-binder analogue of newTestRuntimeWithStdin in runner_input_test.go.
func newTypedInputRuntime(t *testing.T, specs []fieldSpec, argv []string, stdin string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags(argv); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
return &RuntimeContext{
Cmd: cmd,
Factory: &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{In: strings.NewReader(stdin)},
},
}
}
// --- @file / stdin on typed shortcuts -------------------------------------
type fileInputArgs struct {
Content string `flag:"content" input:"file,stdin"`
}
func TestResolveTypedInputs_File(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
body := "## Title\n\nbody from a file\n"
if err := os.WriteFile("body.md", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
specs, err := walkArgs(reflect.TypeOf(&fileInputArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
rt := newTypedInputRuntime(t, specs, []string{"--content", "@body.md"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
if err := bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Content != body {
t.Errorf("Content = %q, want file body %q", out.Content, body)
}
}
func TestResolveTypedInputs_Stdin(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
rt := newTypedInputRuntime(t, specs, []string{"--content", "-"}, "piped stdin body")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
if out.Content != "piped stdin body" {
t.Errorf("Content = %q, want stdin body", out.Content)
}
}
func TestResolveTypedInputs_PlainValueUnchanged(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&fileInputArgs{}))
rt := newTypedInputRuntime(t, specs, []string{"--content", "literal text"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &fileInputArgs{}
_ = bindFlags(rt.Cmd, reflect.ValueOf(out).Elem(), specs)
if out.Content != "literal text" {
t.Errorf("Content = %q, want unchanged literal", out.Content)
}
}
// A OneOf variant flag that declares @file/stdin must resolve too — the binder
// recurses into buckets because cobra flags are flat regardless of nesting.
type nestedInputVariant struct {
Body *string `flag:"body" input:"file,stdin"`
Raw *string `flag:"raw"`
}
func (nestedInputVariant) OneOf() {}
type nestedInputArgs struct {
Content nestedInputVariant
}
func TestResolveTypedInputs_NestedInOneOf(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
body := "nested variant body\n"
if err := os.WriteFile("v.md", []byte(body), 0o644); err != nil {
t.Fatal(err)
}
specs, err := walkArgs(reflect.TypeOf(&nestedInputArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
rt := newTypedInputRuntime(t, specs, []string{"--body", "@v.md"}, "")
if err := resolveTypedInputs(rt, specs); err != nil {
t.Fatalf("resolveTypedInputs: %v", err)
}
out := &nestedInputArgs{}
if err := bindBuckets(rt.Cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindBuckets: %v", err)
}
if out.Content.Body == nil {
t.Fatal("Content.Body is nil — variant not bound")
}
if *out.Content.Body != body {
t.Errorf("Content.Body = %q, want file body %q", *out.Content.Body, body)
}
}
// --- Mount-time validation: enum / input only on string leaves ------------
type enumOnIntArgs struct {
Level int `flag:"level" enum:"1,2,3"`
}
func TestWalkArgs_EnumOnNonStringErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&enumOnIntArgs{}))
if err == nil {
t.Fatal("expected error for enum on int field")
}
if !strings.Contains(err.Error(), "enum tag is only supported on string") {
t.Errorf("unexpected error: %v", err)
}
}
type inputOnBoolArgs struct {
Flag bool `flag:"flag" input:"file"`
}
func TestWalkArgs_InputOnNonStringErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&inputOnBoolArgs{}))
if err == nil {
t.Fatal("expected error for input on bool field")
}
if !strings.Contains(err.Error(), "input tag is only supported on string") {
t.Errorf("unexpected error: %v", err)
}
}
type unknownInputSrcArgs struct {
Content string `flag:"content" input:"bogus"`
}
func TestWalkArgs_UnknownInputSource(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&unknownInputSrcArgs{}))
if err == nil {
t.Fatal("expected error for unknown input source")
}
if !strings.Contains(err.Error(), "unknown input source") {
t.Errorf("unexpected error: %v", err)
}
}
// A string-alias enum field (e.g. an argstype primitive) must be accepted.
type enumOnStringArgs struct {
Priority string `flag:"priority" enum:"low,normal,high"`
}
func TestWalkArgs_EnumOnStringOK(t *testing.T) {
specs, err := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(specs) != 1 || len(specs[0].EnumValues) != 3 {
t.Errorf("enum not parsed: %+v", specs)
}
}
// --- enum shell completion + help candidate rendering ---------------------
func TestRegisterLeaf_EnumCompletion(t *testing.T) {
prev := cmdutil.FlagCompletionsEnabled()
cmdutil.SetFlagCompletionsEnabled(true)
defer cmdutil.SetFlagCompletionsEnabled(prev)
specs, _ := walkArgs(reflect.TypeOf(&enumOnStringArgs{}))
cmd := &cobra.Command{Use: "test"}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
fn, ok := cmd.GetFlagCompletionFunc("priority")
if !ok || fn == nil {
t.Fatal("expected a completion func registered for --priority")
}
vals, _ := fn(cmd, nil, "")
want := map[string]bool{"low": true, "normal": true, "high": true}
if len(vals) != 3 {
t.Fatalf("completion candidates = %v, want low/normal/high", vals)
}
for _, v := range vals {
if !want[v] {
t.Errorf("unexpected completion candidate %q", v)
}
}
}
func TestFormatLeafLine_EnumCandidates(t *testing.T) {
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "normal", "high"}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(one of: low|normal|high)") {
t.Errorf("help line missing enum candidates: %q", line)
}
}
func TestFormatLeafLine_EnumAndDefault(t *testing.T) {
s := fieldSpec{FlagName: "priority", Description: "the priority", EnumValues: []string{"low", "high"}, DefaultValue: "low"}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(one of: low|high)") || !strings.Contains(line, `(default "low")`) {
t.Errorf("help line missing enum or default: %q", line)
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// --- []<named string> and named slice types bind without panicking --------
// Regression guard: reflect.Convert([]string -> []myID) panics, so the binder
// must build the slice element-by-element via SetString instead.
type myID string
type namedElemArgs struct {
Xs []myID `flag:"xs"`
}
func TestSliceFlag_NamedElementType(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, err := walkArgs(reflect.TypeOf(&namedElemArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags([]string{"--xs", "a,b"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &namedElemArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bind: %v", err)
}
if !reflect.DeepEqual(out.Xs, []myID{"a", "b"}) {
t.Errorf("Xs = %#v, want []myID{a b}", out.Xs)
}
}
type myIDList []string
type namedSliceArgs struct {
Ids myIDList `flag:"ids"`
}
func TestSliceFlag_NamedSliceType(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&namedSliceArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--ids", "x,y,z"})
out := &namedSliceArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bind: %v", err)
}
if !reflect.DeepEqual(out.Ids, myIDList{"x", "y", "z"}) {
t.Errorf("Ids = %#v, want myIDList{x y z}", out.Ids)
}
}
// --- nil Execute → not mounted (parity with legacy Shortcut) ---------------
type nilExecArgs struct {
Name string `flag:"name"`
}
func TestMountTyped_NilExecuteNotMounted(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*nilExecArgs]{
Service: "x", Command: "+noexec", AuthTypes: []string{"user"}, Risk: "read",
// Execute intentionally nil — legacy skips mounting such shortcuts.
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
if sub, _, _ := root.Find([]string{"+noexec"}); sub != nil && sub.Name() == "+noexec" {
t.Error("nil-Execute typed shortcut must NOT be mounted (parity with legacy)")
}
}
func TestMountTyped_WithExecuteMounted(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*nilExecArgs]{
Service: "x", Command: "+yesexec", AuthTypes: []string{"user"}, Risk: "read",
Execute: func(ctx context.Context, args *nilExecArgs, rt *RuntimeContext) error { return nil },
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
if sub, _, _ := root.Find([]string{"+yesexec"}); sub == nil || sub.Name() != "+yesexec" {
t.Error("typed shortcut with Execute should be mounted")
}
}

View File

@@ -0,0 +1,192 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
// --- multi-value ([]string) flags -----------------------------------------
type sliceArgs struct {
Ids []string `flag:"ids"` // default → StringSlice (comma-split)
Tags []string `flag:"tags" split:"none"` // StringArray (repeatable, no split)
}
func TestSliceFlag_StringSliceDefault(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, err := walkArgs(reflect.TypeOf(&sliceArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
if err := cmd.ParseFlags([]string{"--ids", "a,b,c"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &sliceArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bind: %v", err)
}
if !reflect.DeepEqual(out.Ids, []string{"a", "b", "c"}) {
t.Errorf("Ids = %#v, want [a b c] (comma-split)", out.Ids)
}
}
func TestSliceFlag_StringArrayNoSplit(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
_ = registerFlags(cmd, specs)
// repeated; a value containing a comma must NOT be split (StringArray)
if err := cmd.ParseFlags([]string{"--tags", "a,b", "--tags", "c"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &sliceArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if !reflect.DeepEqual(out.Tags, []string{"a,b", "c"}) {
t.Errorf("Tags = %#v, want [\"a,b\" \"c\"] (no split, repeatable)", out.Tags)
}
}
func TestSliceFlag_UnsetIsEmpty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&sliceArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &sliceArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if len(out.Ids) != 0 {
t.Errorf("Ids = %#v, want empty when unset", out.Ids)
}
}
type sliceGroup struct {
Items []string `flag:"items"`
Note string `flag:"note"`
}
type groupSliceArgs struct {
G sliceGroup
}
func TestSliceFlag_InGroup(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, err := walkArgs(reflect.TypeOf(&groupSliceArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
_ = registerFlags(cmd, specs)
if err := cmd.ParseFlags([]string{"--items", "x,y", "--note", "hi"}); err != nil {
t.Fatalf("parse: %v", err)
}
out := &groupSliceArgs{}
if err := bindGroups(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if !reflect.DeepEqual(out.G.Items, []string{"x", "y"}) {
t.Errorf("G.Items = %#v, want [x y]", out.G.Items)
}
}
// --- Mount-time validation for slices / split -----------------------------
type splitOnStringArgs struct {
S string `flag:"s" split:"none"`
}
func TestWalkArgs_SplitOnNonSliceErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&splitOnStringArgs{}))
if err == nil || !strings.Contains(err.Error(), "split tag is only supported on []string") {
t.Fatalf("expected split-on-non-slice error, got %v", err)
}
}
type intSliceArgs struct {
N []int `flag:"n"`
}
func TestWalkArgs_NonStringSliceErrors(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&intSliceArgs{}))
if err == nil || !strings.Contains(err.Error(), "only []string slices are supported") {
t.Fatalf("expected []int error, got %v", err)
}
}
type unknownSplitArgs struct {
S []string `flag:"s" split:"bogus"`
}
func TestWalkArgs_UnknownSplitMode(t *testing.T) {
_, err := walkArgs(reflect.TypeOf(&unknownSplitArgs{}))
if err == nil || !strings.Contains(err.Error(), "unknown split mode") {
t.Fatalf("expected unknown split mode error, got %v", err)
}
}
// --- per-flag hidden ------------------------------------------------------
type hiddenArgs struct {
Visible string `flag:"visible"`
Secret string `flag:"secret" hidden:"true"`
}
func TestHiddenFlag_RegisteredButHidden(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
_ = registerFlags(cmd, specs)
f := cmd.Flags().Lookup("secret")
if f == nil {
t.Fatal("secret flag not registered")
}
if !f.Hidden {
t.Error("secret flag should be marked hidden")
}
// hidden does not mean disabled — it still binds.
_ = cmd.ParseFlags([]string{"--secret", "shh", "--visible", "v"})
out := &hiddenArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if out.Secret != "shh" {
t.Errorf("Secret = %q, want shh (hidden but functional)", out.Secret)
}
}
func TestHiddenFlag_SkippedInHelp(t *testing.T) {
specs, _ := walkArgs(reflect.TypeOf(&hiddenArgs{}))
cmd := &cobra.Command{Use: "test"}
_ = registerFlags(cmd, specs)
var buf bytes.Buffer
cmd.SetOut(&buf)
buildTypedHelp(specs, nil)(cmd, nil)
out := buf.String()
if !strings.Contains(out, "--visible") {
t.Errorf("help should show --visible:\n%s", out)
}
if strings.Contains(out, "--secret") {
t.Errorf("help must NOT show hidden --secret:\n%s", out)
}
}
// --- @file / stdin help hint ----------------------------------------------
func TestFormatLeafLine_InputHintBoth(t *testing.T) {
s := fieldSpec{FlagName: "content", Description: "the content", Input: []string{File, Stdin}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(supports @file, - for stdin)") {
t.Errorf("missing input hint: %q", line)
}
}
func TestFormatLeafLine_InputHintFileOnly(t *testing.T) {
s := fieldSpec{FlagName: "content", Description: "c", Input: []string{File}}
line := formatLeafLine(" ", s)
if !strings.Contains(line, "(supports @file)") || strings.Contains(line, "stdin") {
t.Errorf("file-only hint wrong: %q", line)
}
}

View File

@@ -0,0 +1,312 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"errors"
"reflect"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
)
type simpleArgs struct {
Name string `flag:"name" desc:"a name"`
Count int `flag:"count" default:"3"`
}
func TestWalkArgs_Simple(t *testing.T) {
specs, err := walkArgs(reflect.TypeOf(&simpleArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
if len(specs) != 2 {
t.Fatalf("expected 2 field specs, got %d", len(specs))
}
if specs[0].FlagName != "name" || specs[1].FlagName != "count" {
t.Errorf("flag names: %+v", specs)
}
}
type dupTagArgs struct {
A string `flag:"x"`
B string `flag:"x"`
}
func TestWalkArgs_DuplicateTagPanics(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic for duplicate flag tag")
}
}()
_, _ = walkArgs(reflect.TypeOf(&dupTagArgs{}))
}
type bindArgs struct {
Name string `flag:"name"`
Count int `flag:"count" default:"7"`
}
func TestRegisterAndBind_StringInt(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
if err := registerFlags(cmd, specs); err != nil {
t.Fatalf("registerFlags: %v", err)
}
_ = cmd.ParseFlags([]string{"--name", "alice", "--count", "12"})
out := &bindArgs{}
if err := bindFlags(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Fatalf("bindFlags: %v", err)
}
if out.Name != "alice" {
t.Errorf("Name = %q, want alice", out.Name)
}
if out.Count != 12 {
t.Errorf("Count = %d, want 12", out.Count)
}
}
func TestRegister_DefaultApplies(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bindArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &bindArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if out.Count != 7 {
t.Errorf("default not applied: Count = %d, want 7", out.Count)
}
}
type withValidatable struct {
ID dummyValidatable `flag:"id"`
}
func TestRunValidateValue_CallsValidatable(t *testing.T) {
v := &withValidatable{}
specs, _ := walkArgs(reflect.TypeOf(v))
rt := &RuntimeContext{}
if err := runValidateValue(rt, reflect.ValueOf(v).Elem(), specs); err != nil {
t.Errorf("runValidateValue: %v", err)
}
}
type oneOfArgs struct {
A *string `flag:"a"`
B *string `flag:"b"`
}
func (oneOfArgs) OneOf() {}
type bucketArgs struct {
Bucket oneOfArgs
}
func TestFrameworkRules_OneOfMissing(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &bucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected oneof_missing error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutOneOfMissing {
t.Errorf("Subtype = %q", ve.Subtype)
}
}
func TestFrameworkRules_OneOfMultiple(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&bucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--a", "1", "--b", "2"})
out := &bucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected oneof_multiple error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
t.Errorf("Subtype = %q", ve.Subtype)
}
}
// --- top-level group binding (bindGroups) ---
type dateRange struct {
From string `flag:"from"`
To string `flag:"to"`
}
type groupValueArgs struct {
Range dateRange
}
func TestBindGroups_TopLevelValueGroup(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupValueArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--from", "2026-01-01", "--to", "2026-12-31"})
out := &groupValueArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Range.From != "2026-01-01" || out.Range.To != "2026-12-31" {
t.Errorf("Range = %+v", out.Range)
}
}
type defaultedGroup struct {
Port string `flag:"port" default:"8080"`
Host string `flag:"host" default:"localhost"`
}
type groupDefaultArgs struct {
Conf defaultedGroup
}
func TestBindGroups_TopLevelValueGroup_AppliesDefaults(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupDefaultArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &groupDefaultArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Conf.Port != "8080" || out.Conf.Host != "localhost" {
t.Errorf("defaults not applied: Conf = %+v", out.Conf)
}
}
type proxyConf struct {
Host string `flag:"proxy-host"`
Port string `flag:"proxy-port"`
}
type groupPtrArgs struct {
Proxy *proxyConf
}
func TestBindGroups_TopLevelPtrGroup_AllocatedWhenChanged(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--proxy-host", "p.example.com"})
out := &groupPtrArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Proxy == nil {
t.Fatal("expected Proxy to be allocated when an inner flag was changed")
}
if out.Proxy.Host != "p.example.com" {
t.Errorf("Proxy.Host = %q", out.Proxy.Host)
}
}
func TestBindGroups_TopLevelPtrGroup_NilWhenAbsent(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&groupPtrArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags(nil)
out := &groupPtrArgs{}
argsVal := reflect.ValueOf(out).Elem()
_ = bindFlags(cmd, argsVal, specs)
if err := bindGroups(cmd, argsVal, specs); err != nil {
t.Fatalf("bindGroups: %v", err)
}
if out.Proxy != nil {
t.Errorf("expected Proxy nil when no inner flag set, got %+v", out.Proxy)
}
}
// --- OneOf with a nested group variant (no oneof_trigger; any inner flag
// counts as attempting that variant) ---
type vidGroup struct {
File string `flag:"vid-file"`
Cover string `flag:"vid-cover"`
}
type contentBucket struct {
Text *string `flag:"ct"`
Video *vidGroup
}
func (contentBucket) OneOf() {}
type contentBucketArgs struct {
Bucket contentBucket
}
func TestCheckOneOf_GroupCompanionAloneTriggersVariant(t *testing.T) {
// Companion --vid-cover alone (no --vid-file) should count as attempting
// the Video variant; OneOf check passes (1 variant attempted) and the
// group completeness check then surfaces shortcut_group_incomplete with
// the specific missing flag, not a misleading shortcut_oneof_missing.
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--vid-cover", "c.png"})
out := &contentBucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected group_incomplete error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutGroupIncomplete {
t.Errorf("Subtype = %q, want shortcut_group_incomplete", ve.Subtype)
}
}
func TestCheckOneOf_GroupVariantBothFieldsOK(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--vid-file", "v.mp4", "--vid-cover", "c.png"})
out := &contentBucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
if err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs); err != nil {
t.Errorf("expected OK, got %v", err)
}
}
func TestCheckOneOf_SimpleAndGroupBothAttempted_Multiple(t *testing.T) {
// Text variant set AND Video group's companion set → both variants are
// attempted; expect shortcut_oneof_multiple.
cmd := &cobra.Command{Use: "test"}
specs, _ := walkArgs(reflect.TypeOf(&contentBucketArgs{}))
_ = registerFlags(cmd, specs)
_ = cmd.ParseFlags([]string{"--ct", "hi", "--vid-cover", "c.png"})
out := &contentBucketArgs{}
_ = bindFlags(cmd, reflect.ValueOf(out).Elem(), specs)
err := runFrameworkRules(cmd, reflect.ValueOf(out).Elem(), specs)
if err == nil {
t.Fatal("expected oneof_multiple error")
}
ve := mustValidationError(t, err)
if ve.Subtype != errs.SubtypeShortcutOneOfMultiple {
t.Errorf("Subtype = %q, want shortcut_oneof_multiple", ve.Subtype)
}
}
func mustValidationError(t *testing.T, err error) *errs.ValidationError {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
return ve
}

View File

@@ -164,9 +164,6 @@ func CheckApiError(w io.Writer, result interface{}, action string) bool {
}
// HandleApiResult checks for network/API errors and returns the "data" field.
//
// Deprecated: use RuntimeContext.CallAPITyped (or ClassifyAPIResponse for
// self-driven requests) for typed error envelopes.
func HandleApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
if err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// ShortcutDescriptor exposes the read-only metadata that auth, scope-hint,
// shortcuts.json generation, and diagnose-scope consumers need. Both legacy
// Shortcut and the new TypedShortcut[T] satisfy it (see types.go and
// typed_shortcut.go for the implementations).
type ShortcutDescriptor interface {
GetService() string
GetCommand() string
GetDescription() string
GetAuthTypes() []string
GetRisk() string
ScopesForIdentity(identity string) []string
ConditionalScopesForIdentity(identity string) []string
DeclaredScopesForIdentity(identity string) []string
}
// Mountable is the registration contract for register.go. ShortcutDescriptor
// is embedded so a single interface slice covers both metadata reads and
// cobra mounting.
type Mountable interface {
ShortcutDescriptor
MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory)
}
// OneOfMarker is the opt-in marker for "exactly one variant" sub-structs.
// Variant fields must be pointers; the binder treats a non-nil pointer as
// "this variant was selected by user-set trigger flag". See spec §"OneOf
// trigger semantics" for the trigger rule.
type OneOfMarker interface {
OneOf()
}
// Validatable is implemented by typed primitives (and may be by sub-structs
// that validate a composite value, e.g. a raw JSON body) that own their
// format check. The binder calls
// ValidateValue per field after Normalize. Returning an error must produce
// a *errs.ValidationError so the stderr envelope carries type/subtype/param.
type Validatable interface {
ValidateValue(rt *RuntimeContext, flagName string) error
}
// Normalizable[T] is implemented by typed primitives that canonicalize raw
// user input (e.g. SpreadsheetRef extracting token from URL). The binder
// calls Normalize before ValidateValue and writes the canonical value back.
// hints, if any, are written to stderr once during the Validate phase.
//
// Local-path Normalize MUST NOT emit absolute paths in hints (log safety).
type Normalizable[T any] interface {
Normalize(ctx context.Context, raw string) (value T, hints []string, err error)
}
// ArgsValidator is the cross-field escape hatch. An Args struct may opt in
// by adding this method; the binder calls it after framework-derived
// validation (required / enum / OneOf / group), before the user-defined
// TypedShortcut.Validate hook.
type ArgsValidator interface {
Validate(ctx context.Context, rt *RuntimeContext) error
}
// HelpExample appears in TypedShortcut.Examples and is rendered by
// typed_help under an "EXAMPLES:" section.
type HelpExample struct {
Title string
Cmd string
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
)
// dummyOneOf demonstrates how a sub-struct opts into OneOfMarker by adding
// the empty OneOf() method.
type dummyOneOf struct{ A, B *string }
func (dummyOneOf) OneOf() {}
func TestOneOfMarker_Detection(t *testing.T) {
var v interface{} = dummyOneOf{}
if _, ok := v.(OneOfMarker); !ok {
t.Fatal("dummyOneOf should satisfy OneOfMarker")
}
}
// dummyValidatable implements Validatable.
type dummyValidatable struct{}
func (dummyValidatable) ValidateValue(rt *RuntimeContext, flag string) error { return nil }
func TestValidatable_InterfaceShape(t *testing.T) {
var _ Validatable = dummyValidatable{}
}
// dummyNormalizable implements Normalizable[string].
type dummyNormalizable struct{}
func (dummyNormalizable) Normalize(ctx context.Context, raw string) (string, []string, error) {
return raw, nil, nil
}
func TestNormalizable_InterfaceShape(t *testing.T) {
var n Normalizable[string] = dummyNormalizable{}
got, hints, err := n.Normalize(context.Background(), "x")
if err != nil || got != "x" || hints != nil {
t.Errorf("dummy Normalize round-trip: got=%q hints=%v err=%v", got, hints, err)
}
}
func TestHelpExample_Fields(t *testing.T) {
ex := HelpExample{Title: "send text", Cmd: "--chat-id oc_x --text hi"}
if ex.Title != "send text" || ex.Cmd != "--chat-id oc_x --text hi" {
t.Errorf("HelpExample: got %+v", ex)
}
}

View File

@@ -30,7 +30,6 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// RuntimeContext provides helpers for shortcut execution.
@@ -48,6 +47,7 @@ type RuntimeContext struct {
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
typedArgs any // per-run typed Args, set by binder; consumed by DryRun/Execute
}
// ── Identity ──
@@ -73,16 +73,6 @@ func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot()
}
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
func (ctx *RuntimeContext) Command() string {
if ctx.Cmd == nil {
return ""
}
return ctx.Cmd.Name()
}
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
@@ -211,12 +201,6 @@ func (ctx *RuntimeContext) Int(name string) int {
return v
}
// Float64 returns a float64 flag value (non-integer numbers).
func (ctx *RuntimeContext) Float64(name string) float64 {
v, _ := ctx.Cmd.Flags().GetFloat64(name)
return v
}
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
func (ctx *RuntimeContext) StrArray(name string) []string {
v, _ := ctx.Cmd.Flags().GetStringArray(name)
@@ -642,8 +626,6 @@ func WrapOpenError(err error, pathMsg, readMsg string) error {
// - Other errors → readMsg prefix (default "cannot read file")
//
// Pass an optional readMsg to override the non-path-validation message prefix.
//
// Deprecated: use WrapInputStatErrorTyped for typed error envelopes.
func WrapInputStatError(err error, readMsg ...string) error {
if err == nil {
return nil
@@ -658,28 +640,9 @@ func WrapInputStatError(err error, readMsg ...string) error {
return output.ErrValidation("%s: %s", msg, err)
}
// WrapInputStatErrorTyped wraps a FileIO.Stat/Open error for input file validation.
func WrapInputStatErrorTyped(err error, readMsg ...string) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).
WithCause(err)
}
msg := "cannot read file"
if len(readMsg) > 0 && readMsg[0] != "" {
msg = readMsg[0]
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).
WithCause(err)
}
// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors,
// using standardized messages and the given error category (e.g. "api_error", "io").
// Path validation errors always use ErrValidation (exit code 2).
//
// Deprecated: use WrapSaveErrorTyped for typed error envelopes.
func WrapSaveErrorByCategory(err error, category string) error {
if err == nil {
return nil
@@ -695,28 +658,6 @@ func WrapSaveErrorByCategory(err error, category string) error {
}
}
// WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors.
// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical
// "internal" wire type: call sites migrating from a custom category
// (e.g. "io", "api_error") change their envelope's type field.
func WrapSaveErrorTyped(err error) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).
WithCause(err)
case errors.As(err, &me):
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create parent directory: %s", err).
WithCause(err)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).
WithCause(err)
}
}
// ValidatePath checks that path is a valid relative input path within the
// working directory by delegating to FileIO.Stat. Returns nil if the path is
// valid or does not exist yet; returns an error only for illegal paths
@@ -955,29 +896,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
return runShortcut(cmd, f, &shortcut, botOnly)
},
}
if shortcut.PrintFlagSchema != nil || shortcut.OnInvoke != nil {
onInvoke := shortcut.OnInvoke
relaxRequiredForSchema := shortcut.PrintFlagSchema != nil
// PreRunE runs before cobra's ValidateRequiredFlags. Two opt-in uses:
// - OnInvoke: fire a side effect (e.g. a deprecation notice) that must
// surface even when the call later fails on a missing required flag.
// - --print-schema: pure local introspection; relax the required-flag
// gate so callers don't fill in unrelated flags just to ask for a
// schema (clearing the annotation here is the supported opt-out).
cmd.PreRunE = func(c *cobra.Command, _ []string) error {
if onInvoke != nil {
onInvoke()
}
if relaxRequiredForSchema {
if want, _ := c.Flags().GetBool("print-schema"); want {
c.Flags().VisitAll(func(fl *pflag.Flag) {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
})
}
}
return nil
}
}
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
@@ -991,31 +909,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
// runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
// --print-schema short-circuits everything below: it's pure local
// introspection, no identity / scope / network needed. The flag is
// only registered when the shortcut opts in via PrintFlagSchema.
if s.PrintFlagSchema != nil {
if want, _ := cmd.Flags().GetBool("print-schema"); want {
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
// PrintFlagSchema implementations return bare errors; wrap as a
// structured ExitError so --print-schema (an agent-facing
// introspection path) yields a parseable envelope, not a plain
// string.
if _, ok := err.(*output.ExitError); !ok {
err = output.Errorf(output.ExitValidation, "print_schema_error", "%s", err.Error())
}
return err
}
if len(out) == 0 {
return nil
}
fmt.Fprintln(f.IOStreams.Out, string(out))
return nil
}
}
as, err := resolveShortcutIdentity(cmd, f, s)
if err != nil {
return err
@@ -1120,84 +1013,74 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
return rctx, nil
}
// stripUTF8BOM removes a leading UTF-8 byte-order mark from content read from a
// file or stdin. A BOM that survives into a CSV cell corrupts the first value
// (e.g. "\ufeffNorth", which then makes a MAXIFS/lookup miss it), and a BOM at the
// head of a JSON payload makes json.Unmarshal fail with "invalid character 'ï'".
// Some editors and exporters add it silently. Only a leading BOM is removed; interior
// occurrences are left untouched.
func stripUTF8BOM(s string) string {
return strings.TrimPrefix(s, "\uFEFF")
}
// resolveInputFlags resolves @file and - (stdin) for flags with Input sources.
// Must be called before Validate/DryRun/Execute so that runtime.Str() returns resolved content.
func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
stdinUsed := false
for _, fl := range flags {
if len(fl.Input) == 0 {
continue
if err := resolveInputForFlag(rctx, fl.Name, fl.Input, &stdinUsed); err != nil {
return err
}
raw, err := rctx.Cmd.Flags().GetString(fl.Name)
}
return nil
}
// resolveInputForFlag applies @file / stdin / @@-escape resolution to a single
// string flag. sources lists the accepted inputs (File / Stdin); empty sources
// or an empty/plain flag value is a no-op. stdinUsed is shared across all flags
// of one invocation so stdin (-) is consumed by at most one flag. Both the
// legacy resolveInputFlags loop and the typed binder (resolveTypedInputs) call
// through here, so @file / stdin behaves identically on TypedShortcut[T].
func resolveInputForFlag(rctx *RuntimeContext, name string, sources []string, stdinUsed *bool) error {
if len(sources) == 0 {
return nil
}
raw, err := rctx.Cmd.Flags().GetString(name)
if err != nil {
return FlagErrorf("--%s: Input is only supported for string flags", name)
}
if raw == "" {
return nil
}
// stdin: -
if raw == "-" {
if !slices.Contains(sources, Stdin) {
return FlagErrorf("--%s does not support stdin (-)", name)
}
if *stdinUsed {
return FlagErrorf("--%s: stdin (-) can only be used by one flag", name)
}
*stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: Input is only supported for string flags", fl.Name).
WithParam("--" + fl.Name)
}
if raw == "" {
continue
return FlagErrorf("--%s: failed to read from stdin: %v", name, err)
}
rctx.Cmd.Flags().Set(name, string(data))
return nil
}
// stdin: -
if raw == "-" {
if !slices.Contains(fl.Input, Stdin) {
return ValidationErrorf("--%s does not support stdin (-)", fl.Name).
WithParam("--" + fl.Name)
}
if stdinUsed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--" + fl.Name)
}
stdinUsed = true
data, err := io.ReadAll(rctx.IO().In)
if err != nil {
return ValidationErrorf("--%s: failed to read from stdin: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
}
// strip a leading UTF-8 BOM so it can't corrupt the first CSV
// cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
continue
}
// escape: @@ → literal @
if strings.HasPrefix(raw, "@@") {
rctx.Cmd.Flags().Set(name, raw[1:]) // strip first @
return nil
}
// escape: @@ → literal @
if strings.HasPrefix(raw, "@@") {
rctx.Cmd.Flags().Set(fl.Name, raw[1:]) // strip first @
continue
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(sources, File) {
return FlagErrorf("--%s does not support file input (@path)", name)
}
// file: @path
if strings.HasPrefix(raw, "@") {
if !slices.Contains(fl.Input, File) {
return ValidationErrorf("--%s does not support file input (@path)", fl.Name).
WithParam("--" + fl.Name)
}
path := strings.TrimSpace(raw[1:])
if path == "" {
return ValidationErrorf("--%s: file path cannot be empty after @", fl.Name).
WithParam("--" + fl.Name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name).
WithCause(err)
}
// strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream.
rctx.Cmd.Flags().Set(fl.Name, stripUTF8BOM(string(data)))
continue
path := strings.TrimSpace(raw[1:])
if path == "" {
return FlagErrorf("--%s: file path cannot be empty after @", name)
}
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil {
return FlagErrorf("--%s: %v", name, err)
}
rctx.Cmd.Flags().Set(name, string(data))
return nil
}
return nil
}
@@ -1219,8 +1102,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
}
}
if !valid {
return ValidationErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", ")).
WithParam("--" + fl.Name)
return FlagErrorf("invalid value %q for --%s, allowed: %s", val, fl.Name, strings.Join(fl.Enum, ", "))
}
}
return nil
@@ -1228,8 +1110,7 @@ func validateEnumFlags(rctx *RuntimeContext, flags []Flag) error {
func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut) error {
if s.DryRun == nil {
return ValidationErrorf("--dry-run is not supported for %s %s", s.Service, s.Command).
WithParam("--dry-run")
return FlagErrorf("--dry-run is not supported for %s %s", s.Service, s.Command)
}
fmt.Fprintln(f.IOStreams.ErrOut, "=== Dry Run ===")
dryResult := s.DryRun(rctx.ctx, rctx)
@@ -1282,10 +1163,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
var d int
fmt.Sscanf(fl.Default, "%d", &d)
cmd.Flags().Int(fl.Name, d, desc)
case "float64":
var d float64
fmt.Sscanf(fl.Default, "%g", &d)
cmd.Flags().Float64(fl.Name, d, desc)
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, desc)
case "string_slice":
@@ -1313,24 +1190,19 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}
if s.PrintFlagSchema != nil {
// Guard against a shortcut that already declares these reserved
// introspection flags: pflag panics on a duplicate registration.
// Mirrors the Lookup guard on --format above.
if cmd.Flags().Lookup("print-schema") == nil {
cmd.Flags().Bool("print-schema", false, "print JSON Schema for a composite flag instead of executing")
}
if cmd.Flags().Lookup("flag-name") == nil {
cmd.Flags().String("flag-name", "", "flag whose schema to print (omit to list introspectable flags); used with --print-schema")
}
}
cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output")
cmdutil.AddShortcutIdentityFlag(ctx, cmd, f, s.AuthTypes)
}
// TypedArgs returns the per-run Args struct populated by the binder. Returns
// nil for runs that didn't go through TypedShortcut[T]. Callers should
// type-assert to the concrete *Args type.
func (ctx *RuntimeContext) TypedArgs() any { return ctx.typedArgs }
// SetTypedArgs stores the per-run Args struct. Called once by the binder
// after bind + Normalize complete. Must not be called by user hooks.
func (ctx *RuntimeContext) SetTypedArgs(v any) { ctx.typedArgs = v }

View File

@@ -56,3 +56,17 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
t.Fatalf("expected no error for empty args, got: %v", err)
}
}
func TestRuntimeContext_TypedArgs_RoundTrip(t *testing.T) {
type sample struct{ X int }
rt := &RuntimeContext{}
if got := rt.TypedArgs(); got != nil {
t.Fatalf("fresh RuntimeContext.TypedArgs() = %v, want nil", got)
}
in := &sample{X: 42}
rt.SetTypedArgs(in)
out, ok := rt.TypedArgs().(*sample)
if !ok || out != in {
t.Errorf("round-trip failed: got %v ok=%v", rt.TypedArgs(), ok)
}
}

View File

@@ -96,116 +96,3 @@ func TestShortcutMount_FlagCompletionsDisabled(t *testing.T) {
t.Fatal("did not expect completion func for --format when disabled")
}
}
// TestShortcutMount_ReservedIntrospectionFlagCollision verifies the reserved
// --print-schema / --flag-name flags are registered defensively: a shortcut
// that already declares same-named flags must not trigger pflag's duplicate-
// registration panic (the Lookup guard in registerShortcutFlagsWithContext).
func TestShortcutMount_ReservedIntrospectionFlagCollision(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "docs",
Command: "+introspect",
Description: "x",
// The shortcut's own flags collide with the names the runner auto-
// injects when PrintFlagSchema is set. Without the guard, pflag panics.
Flags: []Flag{
{Name: "print-schema", Desc: "user-defined collision"},
{Name: "flag-name", Desc: "user-defined collision"},
},
PrintFlagSchema: func(string) ([]byte, error) { return nil, nil },
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Mount panicked on a reserved-flag name collision (Lookup guard missing?): %v", r)
}
}()
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+introspect"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if cmd.Flags().Lookup("print-schema") == nil {
t.Error("print-schema flag should still exist after the guarded registration")
}
if cmd.Flags().Lookup("flag-name") == nil {
t.Error("flag-name flag should still exist after the guarded registration")
}
}
func TestShortcutMount_JsonFlag_AcceptedWhenHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+read",
Description: "test read",
HasFormat: true,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+read"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered on HasFormat shortcut")
}
}
func TestShortcutMount_JsonFlag_SkippedWhenConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+update",
Description: "test update",
HasFormat: true,
Flags: []Flag{
{Name: "json", Desc: "body JSON object", Required: true},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+update"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --json flag exists (from custom Flags), but should be the string type, not bool.
flag := cmd.Flags().Lookup("json")
if flag == nil {
t.Fatal("expected --json flag from custom Flags")
}
if flag.DefValue != "" {
t.Errorf("expected empty default (string flag), got %q", flag.DefValue)
}
}
func TestShortcutMount_JsonFlag_RegisteredWithoutHasFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
shortcut := Shortcut{
Service: "test",
Command: "+write",
Description: "test write",
HasFormat: false,
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
shortcut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+write"})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
// --format is now registered for all shortcuts (regardless of HasFormat),
// so --json should also be present.
if flag := cmd.Flags().Lookup("json"); flag == nil {
t.Fatal("expected --json flag to be registered even when HasFormat is false")
}
}

View File

@@ -129,7 +129,6 @@ func TestResolveInputFlags_StdinNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for stdin not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support stdin") {
t.Errorf("unexpected error: %v", err)
}
@@ -143,7 +142,6 @@ func TestResolveInputFlags_FileNotSupported(t *testing.T) {
if err == nil {
t.Fatal("expected error for file not supported")
}
assertValidationParam(t, err, "--data")
if !strings.Contains(err.Error(), "does not support file input") {
t.Errorf("unexpected error: %v", err)
}
@@ -160,7 +158,6 @@ func TestResolveInputFlags_FileNotFound(t *testing.T) {
if err == nil {
t.Fatal("expected error for missing file")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "cannot read file") {
t.Errorf("unexpected error: %v", err)
}
@@ -174,7 +171,6 @@ func TestResolveInputFlags_EmptyFilePath(t *testing.T) {
if err == nil {
t.Fatal("expected error for empty file path")
}
assertValidationParam(t, err, "--markdown")
if !strings.Contains(err.Error(), "file path cannot be empty after @") {
t.Errorf("unexpected error: %v", err)
}
@@ -216,58 +212,7 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
if err == nil {
t.Fatal("expected error for duplicate stdin usage")
}
assertValidationParam(t, err, "--b")
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
t.Errorf("unexpected error: %v", err)
}
}
func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"},
{"no BOM unchanged", "hello", "hello"},
{"empty unchanged", "", ""},
{"only BOM becomes empty", "\uFEFF", ""},
{"interior BOM preserved", "a\uFEFFb", "a\uFEFFb"},
{"only the first BOM removed", "\uFEFF\uFEFFx", "\uFEFFx"},
}
for _, c := range cases {
if got := stripUTF8BOM(c.in); got != c.want {
t.Errorf("%s: stripUTF8BOM(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
}
func TestResolveInputFlags_StripBOMStdin(t *testing.T) {
// A CSV piped via stdin with a leading BOM (e.g. from an upstream export)
// must reach the shortcut without the BOM, so it can't corrupt the first cell.
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "-"}, "\uFEFFname,age\nzhang,8")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("csv"); got != "name,age\nzhang,8" {
t.Errorf("leading BOM not stripped from stdin, got %q", got)
}
}
func TestResolveInputFlags_StripBOMFile(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
// A JSON operations file saved with a BOM would otherwise fail json.Unmarshal
// with "invalid character 'ï'".
if err := os.WriteFile("ops.json", []byte("\uFEFF[{\"shortcut\":\"+cells-set\"}]"), 0644); err != nil {
t.Fatal(err)
}
rctx := newTestRuntimeWithStdin(map[string]string{"operations": "@ops.json"}, "")
flags := []Flag{{Name: "operations", Input: []string{File, Stdin}}}
if err := resolveInputFlags(rctx, flags); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := rctx.Str("operations"); got != "[{\"shortcut\":\"+cells-set\"}]" {
t.Errorf("leading BOM not stripped from file, got %q", got)
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import "testing"
func TestValidateEnumFlags_ReturnsTypedValidation(t *testing.T) {
rctx := newTestRuntime(map[string]string{"mode": "delete"})
err := validateEnumFlags(rctx, []Flag{
{Name: "mode", Enum: []string{"append", "overwrite"}},
})
assertValidationParam(t, err, "--mode")
}
func TestHandleShortcutDryRunUnsupported_ReturnsTypedValidation(t *testing.T) {
err := handleShortcutDryRun(nil, nil, &Shortcut{
Service: "doc",
Command: "fetch",
})
assertValidationParam(t, err, "--dry-run")
}

View File

@@ -0,0 +1,229 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"fmt"
"io"
"reflect"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/internal/cmdutil"
)
// buildTypedHelp returns a cobra.HelpFunc that renders typed shortcuts in
// sections (CHOOSE ONE <FIELD> / OPTIONAL / EXAMPLES / GLOBAL FLAGS / Risk: /
// Tips:). Cobra's default HelpFunc is preserved for all non-typed commands;
// we only override per-command via cmd.SetHelpFunc.
//
// Section titles use the Args struct's field name (e.g. "TARGET", "CONTENT"),
// not the inner Go type name behind the field, so the help mirrors the
// user-visible variable name rather than an implementation detail.
func buildTypedHelp(specs []fieldSpec, examples []HelpExample) func(*cobra.Command, []string) {
return func(cmd *cobra.Command, _ []string) {
w := cmd.OutOrStdout()
fmt.Fprintf(w, "%s — %s\n\n", cmd.Use, cmd.Short)
rendered := map[string]struct{}{}
renderOneOfSections(w, specs, rendered)
renderRequiredSection(w, specs, rendered)
renderOptionalSection(w, specs, rendered)
renderExamples(w, examples)
renderGlobalFlags(w, cmd, rendered)
if r, ok := cmdutil.GetRisk(cmd); ok && r != "" {
fmt.Fprintf(w, "Risk: %s\n\n", r)
}
for _, tip := range cmdutil.GetTips(cmd) {
fmt.Fprintf(w, "Tips: %s\n", tip)
}
}
}
// formatLeafLine renders one leaf flag line — "--name description" with an
// optional `(default "x")` suffix when the field declares a default. Reused by
// every section renderer so REQUIRED / OPTIONAL / CHOOSE ONE all surface the
// same information density as cobra's legacy default help.
func formatLeafLine(indent string, s fieldSpec) string {
line := fmt.Sprintf("%s--%s %s", indent, s.FlagName, s.Description)
if len(s.EnumValues) > 0 {
line += fmt.Sprintf(" (one of: %s)", strings.Join(s.EnumValues, "|"))
}
if len(s.Input) > 0 {
var srcs []string
for _, src := range s.Input {
switch src {
case File:
srcs = append(srcs, "@file")
case Stdin:
srcs = append(srcs, "- for stdin")
}
}
line += fmt.Sprintf(" (supports %s)", strings.Join(srcs, ", "))
}
if s.DefaultValue != "" {
line += fmt.Sprintf(" (default %q)", s.DefaultValue)
}
return line
}
// renderOneOfSections walks each OneOf bucket and prints "CHOOSE ONE <FIELD>:"
// followed by every flag inside the bucket — including flags inside nested
// groups (a paired group's companion flag under its trigger) and nested
// raw-content variants (a raw-JSON variant's body + msg-type flags).
func renderOneOfSections(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
for _, s := range specs {
if !s.IsOneOfBkt {
continue
}
fmt.Fprintf(w, "CHOOSE ONE %s:\n", strings.ToUpper(s.GoFieldName))
inner, _ := walkArgs(reflect.PointerTo(s.StructType))
renderFlagsInBucket(w, inner, " ", rendered)
fmt.Fprintln(w)
}
}
// renderFlagsInBucket renders bucket inner flags, recursing into nested group
// / OneOf sub-structs. The structure is FLATTENED — every leaf flag of an
// inner variant renders at the parent's indent. The framework treats any
// inner flag of a group variant equally (providing any of them counts as
// selecting that variant), so the help no longer visually distinguishes a
// "trigger" from a "companion".
func renderFlagsInBucket(w io.Writer, specs []fieldSpec, indent string, rendered map[string]struct{}) {
for _, child := range specs {
if child.IsGroup || child.IsOneOfBkt {
inner, _ := walkArgs(reflect.PointerTo(child.StructType))
for _, leaf := range inner {
if leaf.IsGroup || leaf.IsOneOfBkt {
grand, _ := walkArgs(reflect.PointerTo(leaf.StructType))
renderFlagsInBucket(w, grand, indent+" ", rendered)
continue
}
if leaf.FlagName == "" || leaf.Hidden {
continue
}
fmt.Fprintln(w, formatLeafLine(indent, leaf))
rendered[leaf.FlagName] = struct{}{}
}
continue
}
if child.FlagName == "" || child.Hidden {
continue
}
fmt.Fprintln(w, formatLeafLine(indent, child))
rendered[child.FlagName] = struct{}{}
}
}
// renderRequiredSection prints top-level leaf flags that carry the `required`
// tag under a "REQUIRED:" header so users can see at a glance which flags they
// must supply. Sub-struct fields are skipped here because they're surfaced
// under the CHOOSE ONE sections above.
func renderRequiredSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
anyFlag := false
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
continue
}
if s.FlagName == "" || !s.Required || s.Hidden {
continue
}
if !anyFlag {
fmt.Fprintln(w, "REQUIRED:")
anyFlag = true
}
fmt.Fprintln(w, formatLeafLine(" ", s))
rendered[s.FlagName] = struct{}{}
}
if anyFlag {
fmt.Fprintln(w)
}
}
// renderOptionalSection prints top-level leaf flags that don't belong to any
// OneOf bucket and aren't tagged required (those are handled by
// renderRequiredSection above). Sub-struct fields are skipped here because
// they live under CHOOSE ONE sections above.
func renderOptionalSection(w io.Writer, specs []fieldSpec, rendered map[string]struct{}) {
anyFlag := false
for _, s := range specs {
if s.IsOneOfBkt || s.IsGroup {
continue
}
if s.FlagName == "" || s.Required || s.Hidden {
continue
}
if !anyFlag {
fmt.Fprintln(w, "OPTIONAL:")
anyFlag = true
}
fmt.Fprintln(w, formatLeafLine(" ", s))
rendered[s.FlagName] = struct{}{}
}
if anyFlag {
fmt.Fprintln(w)
}
}
func renderExamples(w io.Writer, examples []HelpExample) {
if len(examples) == 0 {
return
}
fmt.Fprintln(w, "EXAMPLES:")
for _, e := range examples {
fmt.Fprintf(w, " %-20s %s\n", e.Title+":", e.Cmd)
}
fmt.Fprintln(w)
}
// renderGlobalFlags prints framework-injected and cobra-inherited flags
// (--as / --dry-run / --jq / --format / -h / --help) that the typed Args
// struct does NOT define. The `rendered` set captures every flag name we
// already emitted under CHOOSE ONE / OPTIONAL — anything else surfaced by
// cmd.Flags() or cmd.InheritedFlags() falls under GLOBAL FLAGS.
func renderGlobalFlags(w io.Writer, cmd *cobra.Command, rendered map[string]struct{}) {
type globalFlag struct {
Name string
Shorthand string
Usage string
}
var globals []globalFlag
seen := map[string]bool{}
collect := func(fs *pflag.FlagSet) {
fs.VisitAll(func(f *pflag.Flag) {
if f.Hidden {
return
}
if _, alreadyRendered := rendered[f.Name]; alreadyRendered {
return
}
if seen[f.Name] {
return
}
seen[f.Name] = true
globals = append(globals, globalFlag{Name: f.Name, Shorthand: f.Shorthand, Usage: f.Usage})
})
}
collect(cmd.Flags())
collect(cmd.InheritedFlags())
// Cobra auto-injects --help on every command, but it does not appear in
// LocalFlags or InheritedFlags until the command has been resolved at
// invocation time. Ensure it is always documented.
if !seen["help"] {
globals = append(globals, globalFlag{Name: "help", Shorthand: "h", Usage: "show help"})
}
if len(globals) == 0 {
return
}
fmt.Fprintln(w, "GLOBAL FLAGS:")
for _, g := range globals {
if g.Shorthand != "" {
fmt.Fprintf(w, " -%s, --%s %s\n", g.Shorthand, g.Name, g.Usage)
} else {
fmt.Fprintf(w, " --%s %s\n", g.Name, g.Usage)
}
}
fmt.Fprintln(w)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
type helpDemoTarget struct {
Chat *string `flag:"chat-id"`
User *string `flag:"user-id"`
}
func (helpDemoTarget) OneOf() {}
type helpDemoArgs struct {
Target helpDemoTarget
Idemp string `flag:"idempotency-key"`
}
func TestTypedHelp_RendersSections(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
cmdutil.SetRisk(cmd, "high-risk-write")
cmdutil.SetTips(cmd, []string{"call carefully"})
specs, err := walkArgs(reflect.TypeOf(&helpDemoArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
cmd.SetHelpFunc(buildTypedHelp(specs, []HelpExample{{Title: "demo", Cmd: "--chat-id oc_x"}}))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.HelpFunc()(cmd, nil)
out := buf.String()
for _, section := range []string{"CHOOSE ONE", "OPTIONAL", "EXAMPLES", "Risk:", "Tips:"} {
if !strings.Contains(out, section) {
t.Errorf("help missing %q section; got:\n%s", section, out)
}
}
}
// helpReqDefaultsArgs covers two cases the renderer previously botched:
//
// 1. A required flag (--limit) should land under "REQUIRED:" instead of
// being silently glued into "OPTIONAL:".
// 2. A flag with default (--page-size) should display (default "20").
type helpReqDefaultsArgs struct {
Limit string `flag:"limit" required:"true" desc:"max items"`
PageSize string `flag:"page-size" default:"20" desc:"page size"`
Verbose string `flag:"verbose" desc:"output mode"`
}
func TestTypedHelp_RequiredSectionAndDefaults(t *testing.T) {
cmd := &cobra.Command{Use: "+demo", Short: "demo command"}
specs, err := walkArgs(reflect.TypeOf(&helpReqDefaultsArgs{}))
if err != nil {
t.Fatalf("walkArgs: %v", err)
}
cmd.SetHelpFunc(buildTypedHelp(specs, nil))
var buf bytes.Buffer
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.HelpFunc()(cmd, nil)
out := buf.String()
if !strings.Contains(out, "REQUIRED:") {
t.Errorf("expected REQUIRED: section, got:\n%s", out)
}
// --limit must be under REQUIRED, NOT under OPTIONAL.
reqIdx := strings.Index(out, "REQUIRED:")
optIdx := strings.Index(out, "OPTIONAL:")
limitIdx := strings.Index(out, "--limit")
if reqIdx < 0 || optIdx < 0 || limitIdx < 0 {
t.Fatalf("layout markers missing: REQUIRED@%d OPTIONAL@%d --limit@%d\n%s", reqIdx, optIdx, limitIdx, out)
}
if !(reqIdx < limitIdx && limitIdx < optIdx) {
t.Errorf("--limit should appear under REQUIRED before OPTIONAL; got:\n%s", out)
}
// Default value must be surfaced for --page-size.
if !strings.Contains(out, `(default "20")`) {
t.Errorf("expected default value rendering for --page-size; got:\n%s", out)
}
// Plain flag without default or required tag must NOT carry a (default …) suffix.
verboseLine := ""
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "--verbose") {
verboseLine = line
break
}
}
if verboseLine == "" {
t.Fatalf("expected --verbose flag to be rendered; got:\n%s", out)
}
if strings.Contains(verboseLine, "(default") {
t.Errorf("--verbose has no default, should not carry default suffix: %q", verboseLine)
}
}

View File

@@ -0,0 +1,231 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"fmt"
"reflect"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// TypedShortcut is the generic counterpart of the legacy Shortcut struct.
// Args must be a pointer to a struct (e.g. *MyArgs). Mounting reflects the
// struct, registers cobra flags, then delegates to a synthesized Shortcut
// shell so the existing run pipeline (identity / scopes / @file / stdin /
// jq / dry-run / high-risk gate) is reused verbatim.
type TypedShortcut[T any] struct {
Service, Command, Description string
Risk string
Scopes, UserScopes, BotScopes []string
ConditionalScopes []string
ConditionalUserScopes []string
ConditionalBotScopes []string
AuthTypes []string
HasFormat bool
Tips []string
Hidden bool
Examples []HelpExample
DryRun func(ctx context.Context, args T, rt *RuntimeContext) *DryRunAPI
Validate func(ctx context.Context, args T, rt *RuntimeContext) error
Execute func(ctx context.Context, args T, rt *RuntimeContext) error
PostMount func(cmd *cobra.Command)
}
func (s TypedShortcut[T]) GetService() string { return s.Service }
func (s TypedShortcut[T]) GetCommand() string { return s.Command }
func (s TypedShortcut[T]) GetDescription() string { return s.Description }
func (s TypedShortcut[T]) GetAuthTypes() []string { return s.AuthTypes }
func (s TypedShortcut[T]) GetRisk() string { return s.Risk }
func (s TypedShortcut[T]) ScopesForIdentity(identity string) []string {
switch identity {
case "user":
if len(s.UserScopes) > 0 {
return s.UserScopes
}
case "bot":
if len(s.BotScopes) > 0 {
return s.BotScopes
}
}
return s.Scopes
}
func (s TypedShortcut[T]) ConditionalScopesForIdentity(identity string) []string {
switch identity {
case "user":
if len(s.ConditionalUserScopes) > 0 {
return s.ConditionalUserScopes
}
case "bot":
if len(s.ConditionalBotScopes) > 0 {
return s.ConditionalBotScopes
}
}
return s.ConditionalScopes
}
func (s TypedShortcut[T]) DeclaredScopesForIdentity(identity string) []string {
base := s.ScopesForIdentity(identity)
extra := s.ConditionalScopesForIdentity(identity)
if len(base) == 0 && len(extra) == 0 {
return nil
}
out := make([]string, 0, len(base)+len(extra))
seen := map[string]struct{}{}
for _, scope := range append(base, extra...) {
if scope == "" {
continue
}
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
out = append(out, scope)
}
if len(out) == 0 {
return nil
}
return out
}
// Mount registers the typed shortcut on a parent command, mirroring the legacy
// Shortcut.Mount API so migrating common.Shortcut → common.TypedShortcut[T]
// does not force existing callers (or their tests) to also rewrite the Mount
// call site. Delegates to MountWithContext with a background context.
func (s TypedShortcut[T]) Mount(parent *cobra.Command, f *cmdutil.Factory) {
s.MountWithContext(context.Background(), parent, f)
}
// MountWithContext is implemented in Task 16's adapter section.
func (s TypedShortcut[T]) MountWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
mountTyped[T](ctx, parent, f, s)
}
// mountTyped synthesizes a legacy *Shortcut shell that delegates back to the
// typed hooks. The shell's Validate/DryRun/Execute closures read or write
// the per-run typed args via RuntimeContext.{TypedArgs,SetTypedArgs}.
//
// Pipeline order inside the shell (matches runShortcut at runner.go:748):
//
// 1. identity / scopes / runtime — handled by Shortcut.runShortcut
// 2. validateEnumFlags — Shortcut machinery
// 3. resolveInputFlags — @file / stdin (legacy shell only; the
// synthesized shell's Flags slice is empty, so this is a no-op for typed —
// typed flags declare inputs via the `input` tag, resolved in step 5)
// 4. ValidateJqFlags — --jq
// 5. shell.Validate — resolveTypedInputs (@file / stdin for
// `input`-tagged flags), binds T, runs Normalize / ValidateValue /
// framework rules / ArgsValidator / user-typed Validate
// 6. --dry-run gate — shell.DryRun reads typed args from rt
// 7. high-risk-write confirmation — when Risk == "high-risk-write"
// 8. shell.Execute — reads typed args from rt
func mountTyped[T any](ctx context.Context, parent *cobra.Command, f *cmdutil.Factory, s TypedShortcut[T]) {
// Mirror legacy Shortcut.MountWithContext: a shortcut with no Execute is
// not a runnable command, so it is not mounted at all (rather than mounted
// and erroring at invocation time). Keeps the command tree identical to
// legacy after migration.
if s.Execute == nil {
return
}
var zero T
argsType := reflect.TypeOf(zero)
if argsType == nil || argsType.Kind() != reflect.Ptr {
panic("TypedShortcut[T]: T must be a pointer to a struct, got " + fmt.Sprintf("%T", zero))
}
specs, err := walkArgs(argsType)
if err != nil {
panic("TypedShortcut[T].Mount: " + err.Error())
}
shell := Shortcut{
Service: s.Service,
Command: s.Command,
Description: s.Description,
Risk: s.Risk,
Scopes: s.Scopes,
UserScopes: s.UserScopes,
BotScopes: s.BotScopes,
ConditionalScopes: s.ConditionalScopes,
ConditionalUserScopes: s.ConditionalUserScopes,
ConditionalBotScopes: s.ConditionalBotScopes,
AuthTypes: s.AuthTypes,
HasFormat: s.HasFormat,
Tips: s.Tips,
Hidden: s.Hidden,
PostMount: func(cmd *cobra.Command) {
if err := registerFlags(cmd, specs); err != nil {
panic("TypedShortcut[T] registerFlags: " + err.Error())
}
cmd.SetHelpFunc(buildTypedHelp(specs, s.Examples))
if s.PostMount != nil {
s.PostMount(cmd)
}
},
Validate: func(c context.Context, rt *RuntimeContext) error {
// @file / stdin resolution for flags that declared an `input` tag.
// Runs before bindFlags so the resolved file/stdin content is what
// gets bound into the Args struct.
if err := resolveTypedInputs(rt, specs); err != nil {
return err
}
args := reflect.New(argsType.Elem()).Interface().(T)
argsVal := reflect.ValueOf(args).Elem()
if err := bindFlags(rt.Cmd, argsVal, specs); err != nil {
return err
}
if err := bindBuckets(rt.Cmd, argsVal, specs); err != nil {
return err
}
if err := bindGroups(rt.Cmd, argsVal, specs); err != nil {
return err
}
if err := runNormalize(c, rt, argsVal, specs); err != nil {
return err
}
if err := runValidateValue(rt, argsVal, specs); err != nil {
return err
}
if err := runFrameworkRules(rt.Cmd, argsVal, specs); err != nil {
return err
}
if av, ok := any(args).(ArgsValidator); ok {
if err := av.Validate(c, rt); err != nil {
return err
}
}
rt.SetTypedArgs(args)
if s.Validate != nil {
return s.Validate(c, args, rt)
}
return nil
},
Execute: func(c context.Context, rt *RuntimeContext) error {
if s.Execute == nil {
return &errs.InternalError{
Problem: errs.Problem{
Category: errs.CategoryInternal,
Message: "shortcut " + s.Service + " " + s.Command + " has no Execute handler",
},
}
}
args, _ := rt.TypedArgs().(T)
return s.Execute(c, args, rt)
},
}
if s.DryRun != nil {
shell.DryRun = func(c context.Context, rt *RuntimeContext) *DryRunAPI {
args, _ := rt.TypedArgs().(T)
return s.DryRun(c, args, rt)
}
}
shell.MountWithContext(ctx, parent, f)
}

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"slices"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
type stubArgs struct{}
func newTypedFixture() TypedShortcut[*stubArgs] {
return TypedShortcut[*stubArgs]{
Service: "im",
Command: "+demo",
Description: "demo",
AuthTypes: []string{"user"},
Risk: "write",
Scopes: []string{"x"},
}
}
func TestTypedShortcut_Descriptors(t *testing.T) {
ts := newTypedFixture()
if ts.GetService() != "im" {
t.Errorf("GetService=%q", ts.GetService())
}
if ts.GetCommand() != "+demo" {
t.Errorf("GetCommand=%q", ts.GetCommand())
}
if ts.GetDescription() != "demo" {
t.Errorf("GetDescription=%q", ts.GetDescription())
}
if ts.GetRisk() != "write" {
t.Errorf("GetRisk=%q", ts.GetRisk())
}
}
func TestTypedShortcut_SatisfiesMountable(t *testing.T) {
var _ Mountable = newTypedFixture()
}
type adapterArgs struct {
Name string `flag:"name"`
}
// TestMountTyped_RegistersFlags verifies the mountTyped adapter wires the
// binder-registered flag into cobra. Full Validate/Execute integration is
// covered by tests_e2e/shortcuts/ (out of unit-test scope — runShortcut
// needs a fully-initialized Factory with auth/config).
func TestMountTyped_RegistersFlags(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*adapterArgs]{
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
Risk: "read",
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error {
return nil
},
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
sub, _, err := root.Find([]string{"+demo"})
if err != nil {
t.Fatalf("find subcommand: %v", err)
}
if sub.Flag("name") == nil {
t.Error("expected --name flag to be registered via binder")
}
}
func TestMountTyped_HelpFuncInstalled(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*adapterArgs]{
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
Risk: "read",
Examples: []HelpExample{{Title: "demo", Cmd: "--name alice"}},
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
}
ts.MountWithContext(context.Background(), root, &cmdutil.Factory{})
sub, _, _ := root.Find([]string{"+demo"})
if sub == nil || sub.HelpFunc() == nil {
t.Fatal("expected typed help func installed on subcommand")
}
}
// TestTypedShortcut_Mount verifies the no-context convenience Mount API still
// works after migration, mirroring legacy Shortcut.Mount so existing tests of
// migrated shortcuts don't need to switch to MountWithContext.
func TestTypedShortcut_Mount(t *testing.T) {
root := &cobra.Command{Use: "root"}
ts := TypedShortcut[*adapterArgs]{
Service: "x", Command: "+demo", AuthTypes: []string{"user"},
Risk: "read",
Execute: func(ctx context.Context, args *adapterArgs, rt *RuntimeContext) error { return nil },
}
ts.Mount(root, &cmdutil.Factory{})
sub, _, err := root.Find([]string{"+demo"})
if err != nil || sub == nil {
t.Fatalf("find subcommand: %v (sub=%v)", err, sub)
}
}
func TestTypedShortcut_GetAuthTypes(t *testing.T) {
ts := TypedShortcut[*stubArgs]{AuthTypes: []string{"user", "bot"}}
if got := ts.GetAuthTypes(); !slices.Equal(got, []string{"user", "bot"}) {
t.Errorf("GetAuthTypes=%v", got)
}
if got := (TypedShortcut[*stubArgs]{}).GetAuthTypes(); got != nil {
t.Errorf("empty GetAuthTypes=%v, want nil", got)
}
}
func TestTypedShortcut_ScopesForIdentity(t *testing.T) {
ts := TypedShortcut[*stubArgs]{
Scopes: []string{"base"},
UserScopes: []string{"u"},
BotScopes: []string{"b"},
}
cases := []struct {
identity string
want []string
}{
{"user", []string{"u"}},
{"bot", []string{"b"}},
{"other", []string{"base"}},
{"", []string{"base"}},
}
for _, c := range cases {
if got := ts.ScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
t.Errorf("ScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
}
}
// Falls back to Scopes when the identity-specific list is empty.
fallback := TypedShortcut[*stubArgs]{Scopes: []string{"base"}}
if got := fallback.ScopesForIdentity("user"); !slices.Equal(got, []string{"base"}) {
t.Errorf("fallback user=%v", got)
}
}
func TestTypedShortcut_ConditionalScopesForIdentity(t *testing.T) {
ts := TypedShortcut[*stubArgs]{
ConditionalScopes: []string{"cbase"},
ConditionalUserScopes: []string{"cu"},
ConditionalBotScopes: []string{"cb"},
}
cases := []struct {
identity string
want []string
}{
{"user", []string{"cu"}},
{"bot", []string{"cb"}},
{"other", []string{"cbase"}},
}
for _, c := range cases {
if got := ts.ConditionalScopesForIdentity(c.identity); !slices.Equal(got, c.want) {
t.Errorf("ConditionalScopesForIdentity(%q)=%v, want %v", c.identity, got, c.want)
}
}
}
func TestTypedShortcut_DeclaredScopesForIdentity(t *testing.T) {
// Merges base + conditional, dedupes overlap, drops empty strings.
ts := TypedShortcut[*stubArgs]{
UserScopes: []string{"a", "b", ""},
ConditionalUserScopes: []string{"b", "c"},
}
if got := ts.DeclaredScopesForIdentity("user"); !slices.Equal(got, []string{"a", "b", "c"}) {
t.Errorf("merge+dedupe got %v", got)
}
// Returns nil when nothing is declared on either side.
if got := (TypedShortcut[*stubArgs]{}).DeclaredScopesForIdentity("user"); got != nil {
t.Errorf("empty got %v, want nil", got)
}
}

View File

@@ -18,7 +18,7 @@ const (
// Flag describes a CLI flag for a shortcut.
type Flag struct {
Name string // flag name (e.g. "calendar-id")
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
Type string // "string" (default) | "bool" | "int" | "string_array" | "string_slice"
Default string // default value as string
Desc string // help text
Hidden bool // hidden from --help, still readable at runtime
@@ -58,29 +58,6 @@ type Shortcut struct {
Validate func(ctx context.Context, runtime *RuntimeContext) error // optional pre-execution validation
Execute func(ctx context.Context, runtime *RuntimeContext) error // main logic
// OnInvoke, when non-nil, runs from the command's cobra PreRunE — before
// cobra validates required flags — so its side effect fires even when the
// call later fails on a missing required flag (which short-circuits before
// Validate/Execute). The backward-compat aliases use it to record a
// deprecation notice that must surface regardless of whether the call
// validates. Fire-and-forget: no args, no return (e.g. deprecation.SetPending).
OnInvoke func()
// PrintFlagSchema, when non-nil, opts this shortcut into the
// `--print-schema --flag-name <name>` runtime introspection contract.
// The framework auto-injects those two system flags and short-circuits
// Validate/Execute when --print-schema is set, dispatching to this hook.
//
// Contract:
// - flagName == "" → list the flags this shortcut can describe
// (output is impl-defined; agents read this to
// discover which flags are introspectable).
// - flagName == "...": → return the JSON Schema (or schema-like blob)
// for that flag.
// Return value is written to stdout verbatim; callers typically format
// it as JSON. Returning an error surfaces as a normal command error.
PrintFlagSchema func(flagName string) ([]byte, error)
// PostMount is an optional hook called after the cobra.Command is fully
// configured (flags registered, tips set) and after parent.AddCommand(cmd)
// has attached it to the parent. Use it to install custom help functions or
@@ -148,3 +125,23 @@ func (s *Shortcut) DeclaredScopesForIdentity(identity string) []string {
}
return out
}
// GetService returns the parent cobra command name (e.g. "im"). Trivial
// accessor so *Shortcut satisfies ShortcutDescriptor alongside the existing
// pointer-receiver scope methods.
func (s *Shortcut) GetService() string { return s.Service }
// GetCommand returns the shortcut subcommand name (e.g. "+messages-send").
func (s *Shortcut) GetCommand() string { return s.Command }
// GetDescription returns the short help text.
func (s *Shortcut) GetDescription() string { return s.Description }
// GetAuthTypes returns the supported identities. Defaults to ["user"] is
// applied at mount time, not here, to preserve the existing field semantics.
func (s *Shortcut) GetAuthTypes() []string { return s.AuthTypes }
// GetRisk returns the static risk level ("read" / "write" / "high-risk-write").
// Empty string defaults to "read" by convention; callers must handle that
// case (same convention as the existing cobra annotation logic).
func (s *Shortcut) GetRisk() string { return s.Risk }

View File

@@ -105,3 +105,32 @@ func TestDeclaredScopesForIdentity_ConditionalOnly(t *testing.T) {
t.Errorf("expected conditional-only declared scopes, got %v", got)
}
}
func TestShortcut_DescriptorAccessors(t *testing.T) {
s := &Shortcut{
Service: "im",
Command: "+messages-send",
Description: "Send a message",
AuthTypes: []string{"user", "bot"},
Risk: "write",
}
if s.GetService() != "im" {
t.Errorf("GetService = %q", s.GetService())
}
if s.GetCommand() != "+messages-send" {
t.Errorf("GetCommand = %q", s.GetCommand())
}
if s.GetDescription() != "Send a message" {
t.Errorf("GetDescription = %q", s.GetDescription())
}
if got := s.GetAuthTypes(); len(got) != 2 || got[0] != "user" || got[1] != "bot" {
t.Errorf("GetAuthTypes = %v", got)
}
if s.GetRisk() != "write" {
t.Errorf("GetRisk = %q", s.GetRisk())
}
}
func TestShortcut_SatisfiesShortcutDescriptor(t *testing.T) {
var _ ShortcutDescriptor = &Shortcut{}
}

View File

@@ -4,7 +4,6 @@
package common
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
@@ -14,32 +13,9 @@ import (
// open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName is
// used in error messages to point the user at the offending CLI flag.
//
// Deprecated: use ResolveOpenIDsTyped for typed error envelopes.
func ResolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, output.ErrValidation("%s", msg)
}
return out, nil
}
// ResolveOpenIDsTyped expands the special identifier "me" to the current
// user's open_id, removes duplicates case-insensitively while preserving the
// first-occurrence form, and returns nil for an empty input. flagName names
// the flag being resolved (e.g. "--user-ids") and is recorded on the typed
// error.
func ResolveOpenIDsTyped(flagName string, ids []string, runtime *RuntimeContext) ([]string, error) {
out, msg := resolveOpenIDs(flagName, ids, runtime)
if msg != "" {
return nil, ValidationErrorf("%s", msg).WithParam(flagName)
}
return out, nil
}
func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]string, string) {
if len(ids) == 0 {
return nil, ""
return nil, nil
}
currentUserID := runtime.UserOpenId()
seen := make(map[string]struct{}, len(ids))
@@ -47,7 +23,7 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
for _, id := range ids {
if strings.EqualFold(id, "me") {
if currentUserID == "" {
return nil, fmt.Sprintf("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
}
id = currentUserID
}
@@ -58,5 +34,5 @@ func resolveOpenIDs(flagName string, ids []string, runtime *RuntimeContext) ([]s
seen[key] = struct{}{}
out = append(out, id)
}
return out, ""
return out, nil
}

View File

@@ -75,24 +75,3 @@ func TestResolveOpenIDs_DedupIsCaseInsensitive(t *testing.T) {
t.Fatalf("case-insensitive dedup failed: got %v, want [ou_abc123]", out)
}
}
func TestResolveOpenIDsTyped_MeWithoutLogin_ReturnsTypedValidation(t *testing.T) {
rt := resolveOpenIDsTestRuntime("")
_, err := ResolveOpenIDsTyped("--user-ids", []string{"me"}, rt)
validationErr := assertValidationParam(t, err, "--user-ids")
if !strings.Contains(validationErr.Message, "--user-ids") {
t.Fatalf("error should mention the offending flag name; got: %v", err)
}
}
func TestResolveOpenIDsTyped_ExpandsMeAndDedups(t *testing.T) {
rt := resolveOpenIDsTestRuntime("ou_self")
out, err := ResolveOpenIDsTyped("--user-ids", []string{"me", "ou_a", "me", "ou_a"}, rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"ou_self", "ou_a"}
if len(out) != len(want) || out[0] != want[0] || out[1] != want[1] {
t.Fatalf("got %v, want %v", out, want)
}
}

View File

@@ -8,26 +8,16 @@ import (
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
)
// FlagErrorf returns a validation error with flag context (exit code 2).
//
// Deprecated: use ValidationErrorf for typed error envelopes.
func FlagErrorf(format string, args ...any) error {
return output.ErrValidation(format, args...)
}
// ValidationErrorf returns a typed validation error with invalid_argument subtype.
func ValidationErrorf(format string, args ...any) *errs.ValidationError {
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
}
// MutuallyExclusive checks that at most one of the given flags is set.
//
// Deprecated: use MutuallyExclusiveTyped for typed error envelopes.
func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
@@ -42,25 +32,7 @@ func MutuallyExclusive(rt *RuntimeContext, flags ...string) error {
return nil
}
// MutuallyExclusiveTyped checks that at most one of the given flags is set.
func MutuallyExclusiveTyped(rt *RuntimeContext, flags ...string) error {
var set []string
for _, f := range flags {
val := rt.Str(f)
if val != "" {
set = append(set, "--"+f)
}
}
if len(set) > 1 {
return ValidationErrorf("%s are mutually exclusive", strings.Join(set, " and ")).
WithParams(invalidParams(set, "mutually exclusive")...)
}
return nil
}
// AtLeastOne checks that at least one of the given flags is set.
//
// Deprecated: use AtLeastOneTyped for typed error envelopes.
func AtLeastOne(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
@@ -74,24 +46,7 @@ func AtLeastOne(rt *RuntimeContext, flags ...string) error {
return FlagErrorf("specify at least one of %s", strings.Join(names, " or "))
}
// AtLeastOneTyped checks that at least one of the given flags is set.
func AtLeastOneTyped(rt *RuntimeContext, flags ...string) error {
for _, f := range flags {
if rt.Str(f) != "" {
return nil
}
}
names := make([]string, len(flags))
for i, f := range flags {
names[i] = "--" + f
}
return ValidationErrorf("specify at least one of %s", strings.Join(names, " or ")).
WithParams(invalidParams(names, "required; specify at least one")...)
}
// ExactlyOne checks that exactly one of the given flags is set.
//
// Deprecated: use ExactlyOneTyped for typed error envelopes.
func ExactlyOne(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOne(rt, flags...); err != nil {
return err
@@ -99,18 +54,8 @@ func ExactlyOne(rt *RuntimeContext, flags ...string) error {
return MutuallyExclusive(rt, flags...)
}
// ExactlyOneTyped checks that exactly one of the given flags is set.
func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
if err := AtLeastOneTyped(rt, flags...); err != nil {
return err
}
return MutuallyExclusiveTyped(rt, flags...)
}
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
//
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
if s == "" {
@@ -126,25 +71,6 @@ func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, m
return n, nil
}
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
param := "--" + flagName
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, ValidationErrorf("invalid --%s %q: must be an integer", flagName, s).WithParam(param)
}
if n < minVal || n > maxVal {
return 0, ValidationErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal).
WithParam(param)
}
return n, nil
}
// ParseIntBounded parses an int flag and clamps it to [min, max].
func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
v := rt.Int(name)
@@ -161,26 +87,13 @@ func ParseIntBounded(rt *RuntimeContext, name string, min, max int) int {
// working directory. It catches traversal, symlink escape, and control
// characters by delegating to FileIO.ResolvePath. Works for both file and
// directory paths.
//
// Deprecated: use ValidateSafePathTyped for typed error envelopes.
func ValidateSafePath(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
return err
}
// ValidateSafePathTyped ensures path resolves within the current working directory.
func ValidateSafePathTyped(fio fileio.FileIO, path string) error {
_, err := fio.ResolvePath(path)
if err != nil {
return ValidationErrorf("%s", err).WithCause(err)
}
return nil
}
// RejectDangerousChars returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
//
// Deprecated: use RejectDangerousCharsTyped for typed error envelopes.
func RejectDangerousChars(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
@@ -195,31 +108,3 @@ func RejectDangerousChars(paramName, value string) error {
}
return nil
}
// RejectDangerousCharsTyped returns an error if value contains ASCII control
// characters or dangerous Unicode code points.
func RejectDangerousCharsTyped(paramName, value string) error {
for _, r := range value {
if r < 0x20 && r != '\t' && r != '\n' {
return ValidationErrorf("parameter %q contains control character U+%04X", paramName, r).
WithParam(paramName)
}
if r == 0x7F {
return ValidationErrorf("parameter %q contains DEL character", paramName).
WithParam(paramName)
}
if IsDangerousUnicode(r) {
return ValidationErrorf("parameter %q contains dangerous Unicode character U+%04X", paramName, r).
WithParam(paramName)
}
}
return nil
}
func invalidParams(names []string, reason string) []errs.InvalidParam {
params := make([]errs.InvalidParam, len(names))
for i, name := range names {
params[i] = errs.InvalidParam{Name: name, Reason: reason}
}
return params
}

View File

@@ -11,31 +11,10 @@ import (
// ValidateChatID checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided.
//
// Deprecated: use ValidateChatIDTyped for typed error envelopes.
func ValidateChatID(input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return chatID, nil
}
// ValidateChatIDTyped checks if a chat ID has valid format (oc_ prefix).
// Also extracts token from URL if provided. param names the flag being
// validated (e.g. "--chat-ids") and is recorded on the typed error.
func ValidateChatIDTyped(param, input string) (string, error) {
chatID, msg := normalizeChatID(input)
if msg != "" {
return "", ValidationErrorf("%s", msg).WithParam(param)
}
return chatID, nil
}
func normalizeChatID(input string) (string, string) {
input = strings.TrimSpace(input)
if input == "" {
return "", "chat ID cannot be empty"
return "", output.ErrValidation("chat ID cannot be empty")
}
// Extract from URL if present
if strings.Contains(input, "feishu.cn") || strings.Contains(input, "larksuite.com") {
@@ -49,40 +28,19 @@ func normalizeChatID(input string) (string, string) {
}
}
if !strings.HasPrefix(input, "oc_") {
return "", "invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)"
return "", output.ErrValidation("invalid chat ID format, should start with 'oc_' (e.g., oc_abc123)")
}
return input, ""
return input, nil
}
// ValidateUserID checks if a user ID has valid format (ou_ prefix).
//
// Deprecated: use ValidateUserIDTyped for typed error envelopes.
func ValidateUserID(input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", output.ErrValidation("%s", msg)
}
return userID, nil
}
// ValidateUserIDTyped checks if a user ID has valid format (ou_ prefix).
// param names the flag being validated (e.g. "--creator-ids") and is
// recorded on the typed error.
func ValidateUserIDTyped(param, input string) (string, error) {
userID, msg := normalizeUserID(input)
if msg != "" {
return "", ValidationErrorf("%s", msg).WithParam(param)
}
return userID, nil
}
func normalizeUserID(input string) (string, string) {
input = strings.TrimSpace(input)
if input == "" {
return "", "user ID cannot be empty"
return "", output.ErrValidation("user ID cannot be empty")
}
if !strings.HasPrefix(input, "ou_") {
return "", "invalid user ID format, should start with 'ou_' (e.g., ou_abc123)"
return "", output.ErrValidation("invalid user ID format, should start with 'ou_' (e.g., ou_abc123)")
}
return input, ""
return input, nil
}

View File

@@ -4,14 +4,10 @@
package common
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/spf13/cobra"
)
@@ -30,24 +26,6 @@ func newTestRuntime(flags map[string]string) *RuntimeContext {
return &RuntimeContext{Cmd: cmd}
}
func assertValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if param != "" && validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func TestMutuallyExclusive(t *testing.T) {
tests := []struct {
name string
@@ -91,109 +69,6 @@ func TestMutuallyExclusive(t *testing.T) {
}
}
func TestValidationErrorf_ReturnsTypedInvalidArgument(t *testing.T) {
err := ValidationErrorf("bad %s", "flag")
validationErr := assertValidationParam(t, err, "")
if validationErr.Message != "bad flag" {
t.Fatalf("Message = %q, want %q", validationErr.Message, "bad flag")
}
}
func TestTypedFlagGroupHelpers_ReturnValidationParams(t *testing.T) {
t.Run("mutually exclusive", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
validationErr := assertValidationParam(t, MutuallyExclusiveTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
if validationErr.Params[0].Name != "--a" || validationErr.Params[1].Name != "--b" {
t.Fatalf("Params names = %+v, want --a/--b", validationErr.Params)
}
})
t.Run("at least one", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "", "b": ""})
validationErr := assertValidationParam(t, AtLeastOneTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
if !strings.Contains(validationErr.Message, "--a or --b") {
t.Fatalf("Message = %q, want flag group", validationErr.Message)
}
})
t.Run("exactly one", func(t *testing.T) {
rt := newTestRuntime(map[string]string{"a": "x", "b": "y"})
validationErr := assertValidationParam(t, ExactlyOneTyped(rt, "a", "b"), "")
if len(validationErr.Params) != 2 {
t.Fatalf("Params len = %d, want 2: %+v", len(validationErr.Params), validationErr.Params)
}
})
}
func TestValidatePageSizeTyped_ReturnsTypedValidation(t *testing.T) {
rt := newTestRuntime(map[string]string{"page-size": "nope"})
_, err := ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
assertValidationParam(t, err, "--page-size")
rt = newTestRuntime(map[string]string{"page-size": "30"})
_, err = ValidatePageSizeTyped(rt, "page-size", 10, 1, 20)
assertValidationParam(t, err, "--page-size")
}
func TestValidateIDTyped_ReturnsTypedValidation(t *testing.T) {
chatID, err := ValidateChatIDTyped("--chat-ids", "https://example.feishu.cn/foo/oc_abc")
if err != nil {
t.Fatalf("ValidateChatIDTyped valid URL: %v", err)
}
if chatID != "oc_abc" {
t.Fatalf("chatID = %q, want oc_abc", chatID)
}
assertValidationParam(t, func() error {
_, err := ValidateChatIDTyped("--chat-ids", "bad")
return err
}(), "--chat-ids")
assertValidationParam(t, func() error {
_, err := ValidateUserIDTyped("--creator-ids", "bad")
return err
}(), "--creator-ids")
}
func TestRejectDangerousCharsTyped_ReturnsTypedValidation(t *testing.T) {
err := RejectDangerousCharsTyped("--query", "bad\x01")
validationErr := assertValidationParam(t, err, "--query")
if !strings.Contains(validationErr.Message, "control character") {
t.Fatalf("Message = %q, want control character", validationErr.Message)
}
}
func TestWrapInputStatErrorTyped_ReturnsTypedValidation(t *testing.T) {
cause := &fileio.PathValidationError{Err: errors.New("outside cwd")}
err := WrapInputStatErrorTyped(cause)
validationErr := assertValidationParam(t, err, "")
if !strings.Contains(validationErr.Message, "unsafe file path") {
t.Fatalf("Message = %q, want unsafe file path", validationErr.Message)
}
if !errors.Is(err, fileio.ErrPathValidation) {
t.Fatalf("expected errors.Is(fileio.ErrPathValidation) to match")
}
}
func TestWrapSaveErrorTyped_ClassifiesPathAndFileIO(t *testing.T) {
pathErr := &fileio.PathValidationError{Err: errors.New("outside cwd")}
assertValidationParam(t, WrapSaveErrorTyped(pathErr), "")
mkdirErr := &fileio.MkdirError{Err: errors.New("permission denied")}
err := WrapSaveErrorTyped(mkdirErr)
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
if internalErr.Subtype != errs.SubtypeFileIO {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeFileIO)
}
}
func TestAtLeastOne(t *testing.T) {
tests := []struct {
name string
@@ -371,20 +246,3 @@ func TestValidateSafePath_AllowsNonExistentPath(t *testing.T) {
t.Fatalf("expected no error for non-existent path, got: %v", err)
}
}
// TestValidateSafePathTyped_ReturnsTypedValidation verifies that an escaping
// path is rejected with a typed validation error and a safe path passes.
func TestValidateSafePathTyped_ReturnsTypedValidation(t *testing.T) {
outside := t.TempDir()
workDir := t.TempDir()
chdirForTest(t, workDir)
if err := os.Symlink(outside, filepath.Join(workDir, "evil_out")); err != nil {
t.Fatalf("Symlink: %v", err)
}
assertValidationParam(t, ValidateSafePathTyped(&localfileio.LocalFileIO{}, "evil_out"), "")
if err := ValidateSafePathTyped(&localfileio.LocalFileIO{}, "new_output_dir"); err != nil {
t.Fatalf("expected no error for safe path, got: %v", err)
}
}

View File

@@ -354,7 +354,7 @@ func parseDriveSearchPageSize(raw string) (int, error) {
// server-side failure or empty result.
func validateDriveSearchIDs(spec driveSearchSpec) error {
for _, id := range spec.CreatorIDs {
if _, err := common.ValidateUserIDTyped("--creator-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--creator-ids %q: %s", id, err).WithParam("--creator-ids")
}
}
@@ -362,7 +362,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids: max %d values per request, got %d", driveSearchMaxChatIDs, n).WithParam("--chat-ids")
}
for _, id := range spec.ChatIDs {
if _, err := common.ValidateChatIDTyped("--chat-ids", id); err != nil {
if _, err := common.ValidateChatID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-ids %q: %s", id, err).WithParam("--chat-ids")
}
}
@@ -370,7 +370,7 @@ func validateDriveSearchIDs(spec driveSearchSpec) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids: max %d values per request, got %d", driveSearchMaxSharerIDs, n).WithParam("--sharer-ids")
}
for _, id := range spec.SharerIDs {
if _, err := common.ValidateUserIDTyped("--sharer-ids", id); err != nil {
if _, err := common.ValidateUserID(id); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sharer-ids %q: %s", id, err).WithParam("--sharer-ids")
}
}

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
@@ -197,12 +196,8 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
header, _ := card["header"].(cardObj)
title := ""
subtitle := ""
headerTags := ""
if header != nil {
title = c.extractHeaderTitle(header)
subtitle = c.extractHeaderSubtitle(header)
headerTags = c.extractHeaderTags(header)
}
bodyContent := ""
@@ -211,19 +206,13 @@ func (c *cardConverter) convert(jsonCard string, hintSchema int) string {
}
var sb strings.Builder
if title != "" && subtitle != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\" subtitle=\"%s\">\n", cardEscapeAttr(title), cardEscapeAttr(subtitle)))
} else if title != "" {
sb.WriteString(fmt.Sprintf("<card title=\"%s\">\n", cardEscapeAttr(title)))
} else if subtitle != "" {
sb.WriteString(fmt.Sprintf("<card subtitle=\"%s\">\n", cardEscapeAttr(subtitle)))
if title != "" {
sb.WriteString("<card title=\"")
sb.WriteString(cardEscapeAttr(title))
sb.WriteString("\">\n")
} else {
sb.WriteString("<card>\n")
}
if headerTags != "" {
sb.WriteString(headerTags)
sb.WriteString("\n")
}
if bodyContent != "" {
sb.WriteString(bodyContent)
sb.WriteString("\n")
@@ -244,49 +233,6 @@ func (c *cardConverter) extractHeaderTitle(header cardObj) string {
return ""
}
// extractHeaderSubtitle returns the subtitle text of a card header, supporting both
// the property-wrapped and flat element formats.
func (c *cardConverter) extractHeaderSubtitle(header cardObj) string {
if prop, ok := header["property"].(cardObj); ok {
if subtitleElem, ok := prop["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
}
if subtitleElem, ok := header["subtitle"]; ok {
return c.extractTextContent(subtitleElem)
}
return ""
}
// extractHeaderTags returns a space-joined string of header tag labels from textTagList,
// supporting both property-wrapped and flat header formats.
func (c *cardConverter) extractHeaderTags(header cardObj) string {
var prop cardObj
if p, ok := header["property"].(cardObj); ok {
prop = p
} else {
prop = header
}
tagList, ok := prop["textTagList"].([]interface{})
if !ok || len(tagList) == 0 {
return ""
}
var tags []string
for _, tag := range tagList {
tm, ok := tag.(cardObj)
if !ok {
continue
}
if text := c.convertElement(tm, 0); text != "" {
tags = append(tags, text)
}
}
if len(tags) == 0 {
return ""
}
return strings.Join(tags, " ")
}
func (c *cardConverter) convertBody(body cardObj) string {
var elements []interface{}
@@ -533,11 +479,8 @@ func (c *cardConverter) convertDiv(prop cardObj, _ string) string {
if textElem, ok := prop["text"].(cardObj); ok {
if text := c.convertElement(textElem, 0); text != "" {
textProp := c.extractProperty(textElem)
if textStyle, ok := textProp["textStyle"].(cardObj); ok {
if size, _ := textStyle["size"].(string); size == "notation" {
text = "📝 " + text
}
if textSize, _ := textElem["text_size"].(string); textSize == "notation" {
text = "📝 " + text
}
results = append(results, text)
}
@@ -615,14 +558,7 @@ func (c *cardConverter) convertEmoji(prop cardObj) string {
}
func (c *cardConverter) convertLocalDatetime(prop cardObj) string {
var ms string
switch v := prop["milliseconds"].(type) {
case string:
ms = v
case float64:
ms = strconv.FormatInt(int64(v), 10)
}
if ms != "" {
if ms, ok := prop["milliseconds"].(string); ok && ms != "" {
if formatted := cardFormatMillisToISO8601(ms); formatted != "" {
return formatted
}
@@ -853,22 +789,22 @@ func (c *cardConverter) convertCollapsiblePanel(prop cardObj, _ string) string {
}
}
indicator := "▶"
if expanded {
indicator = "▼"
}
var sb strings.Builder
sb.WriteString(indicator + " " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
shouldExpand := expanded || c.mode == cardModeDetailed
if shouldExpand {
var sb strings.Builder
sb.WriteString("▼ " + title + "\n")
if elements, ok := prop["elements"].([]interface{}); ok {
content := c.convertElements(elements, 1)
for _, line := range strings.Split(content, "\n") {
if line != "" {
sb.WriteString(" " + line + "\n")
}
}
}
sb.WriteString("▲")
return sb.String()
}
sb.WriteString("▲")
return sb.String()
return "▶ " + title
}
func (c *cardConverter) convertInteractiveContainer(prop cardObj, id string) string {
@@ -916,17 +852,10 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
}
disabled, _ := prop["disabled"].(bool)
if disabled {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
}
}
return result
if disabled && c.mode == cardModeConcise {
return fmt.Sprintf("[%s ✗]", buttonText)
}
result := fmt.Sprintf("[%s]", buttonText)
if actions, ok := prop["actions"].([]interface{}); ok {
for _, action := range actions {
am, ok := action.(cardObj)
@@ -936,32 +865,24 @@ func (c *cardConverter) convertButton(prop cardObj, _ string) string {
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
if urlStr, ok := ad["url"].(string); ok && urlStr != "" {
result = fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
break
return fmt.Sprintf("[%s](%s)", escapeMDLinkText(buttonText), urlStr)
}
}
}
}
}
if confirmObj, ok := prop["confirm"].(cardObj); ok {
var parts []string
if titleElem, ok := confirmObj["title"]; ok {
if t := c.extractTextContent(titleElem); t != "" {
parts = append(parts, t)
if disabled && c.mode == cardModeDetailed {
result := fmt.Sprintf("[%s ✗]", buttonText)
if tips, ok := prop["disabledTips"].(cardObj); ok {
if tipsText := c.extractTextContent(tips); tipsText != "" {
result += fmt.Sprintf("(tips:\"%s\")", tipsText)
}
}
if textElem, ok := confirmObj["text"]; ok {
if t := c.extractTextContent(textElem); t != "" {
parts = append(parts, t)
}
}
if len(parts) > 0 {
result += fmt.Sprintf("(confirm:\"%s\")", strings.Join(parts, ": "))
}
return result
}
return result
return fmt.Sprintf("[%s]", buttonText)
}
func (c *cardConverter) convertActions(prop cardObj) string {
@@ -993,33 +914,11 @@ func (c *cardConverter) convertOverflow(prop cardObj) string {
if !ok {
continue
}
text := ""
if textElem, ok := om["text"].(cardObj); ok {
text = c.extractTextContent(textElem)
}
if text == "" {
continue
}
urlStr := ""
if actions, ok := om["actions"].([]interface{}); ok {
for _, a := range actions {
am, ok := a.(cardObj)
if !ok {
continue
}
if am["type"] == "open_url" {
if ad, ok := am["action"].(cardObj); ok {
urlStr, _ = ad["url"].(string)
}
}
if text := c.extractTextContent(textElem); text != "" {
optTexts = append(optTexts, text)
}
}
if urlStr != "" {
text = fmt.Sprintf("[%s](%s)", escapeMDLinkText(text), urlStr)
} else if value, _ := om["value"].(string); value != "" {
text += "(" + value + ")"
}
optTexts = append(optTexts, text)
}
return "⋮ " + strings.Join(optTexts, ", ")
}
@@ -1059,20 +958,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
if !ok {
continue
}
value, _ := om["value"].(string)
optText := ""
if textElem, ok := om["text"].(cardObj); ok {
optText = c.extractTextContent(textElem)
}
if optText == "" {
optText = c.lookupOptionUserName(value)
}
if optText == "" {
optText = value
optText, _ = om["value"].(string)
}
if optText == "" {
continue
}
value, _ := om["value"].(string)
if selectedValues[value] {
optText = "✓" + optText
hasSelected = true
@@ -1093,15 +989,17 @@ func (c *cardConverter) convertSelect(prop cardObj, id string, isMulti bool) str
}
result := "{" + strings.Join(optionTexts, " / ") + "}"
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if c.mode == cardModeDetailed && strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
if c.mode == cardModeDetailed {
var attrs []string
if isMulti {
attrs = append(attrs, "multi")
}
if strings.Contains(id, "person") {
attrs = append(attrs, "type:person")
}
if len(attrs) > 0 {
result += "(" + strings.Join(attrs, " ") + ")"
}
}
return result
}
@@ -1127,17 +1025,6 @@ func (c *cardConverter) convertSelectImg(prop cardObj, _ string) string {
}
value, _ := om["value"].(string)
text := fmt.Sprintf("🖼️ Image %d", i+1)
if value != "" {
text += "(" + value + ")"
}
if imageID, ok := om["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
text += "(img_key:" + originKey + ")"
} else if imgToken != "" {
text += "(img_token:" + imgToken + ")"
}
}
if selectedValues[value] {
text = "✓" + text
}
@@ -1240,14 +1127,13 @@ func (c *cardConverter) convertImage(prop cardObj, _ string) string {
}
result := "🖼️ " + alt
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
result += "(img_key:" + originKey + ")"
} else if imgToken != "" {
result += "(img_token:" + imgToken + ")"
} else {
result += "(img_key:" + imageID + ")"
if c.mode == cardModeDetailed {
if imageID, ok := prop["imageID"].(string); ok && imageID != "" {
if token := c.getImageToken(imageID); token != "" {
result += "(img_token:" + token + ")"
} else {
result += "(img_key:" + imageID + ")"
}
}
}
return result
@@ -1259,25 +1145,20 @@ func (c *cardConverter) convertImgCombination(prop cardObj) string {
return ""
}
result := fmt.Sprintf("🖼️ %d image(s)", len(imgList))
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
originKey, imgToken := c.getImageKeyAndToken(imageID)
if originKey != "" {
keys = append(keys, originKey)
} else if imgToken != "" {
keys = append(keys, imgToken)
} else {
if c.mode == cardModeDetailed {
var keys []string
for _, img := range imgList {
im, ok := img.(cardObj)
if !ok {
continue
}
if imageID, ok := im["imageID"].(string); ok && imageID != "" {
keys = append(keys, imageID)
}
}
}
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
if len(keys) > 0 {
result += "(keys:" + strings.Join(keys, ",") + ")"
}
}
return result
}
@@ -1295,11 +1176,7 @@ func (c *cardConverter) convertChart(prop cardObj, _ string) string {
if ct, ok := chartSpec["type"].(string); ok && ct != "" {
chartType = ct
if typeName, ok := cardChartTypeNames[ct]; ok {
if title != "Chart" {
title += " (" + typeName + ")"
} else {
title = typeName
}
title += typeName
}
}
}
@@ -1317,25 +1194,12 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
if !ok {
return ""
}
// VChart spec: data is an array of series objects ([{"id":"...","values":[...]}]).
// Older/object format: data is a map with a "values" key directly.
var values []interface{}
switch d := chartSpec["data"].(type) {
case cardObj:
if v, ok := d["values"].([]interface{}); ok {
values = v
}
case []interface{}:
for _, series := range d {
if sm, ok := series.(cardObj); ok {
if v, ok := sm["values"].([]interface{}); ok {
values = append(values, v...)
}
}
}
dataObj, ok := chartSpec["data"].(cardObj)
if !ok {
return ""
}
if len(values) == 0 {
values, ok := dataObj["values"].([]interface{})
if !ok || len(values) == 0 {
return ""
}
@@ -1380,24 +1244,28 @@ func (c *cardConverter) extractChartSummary(prop cardObj, chartType string) stri
func (c *cardConverter) convertAudio(prop cardObj, _ string) string {
result := "🎵 Audio"
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["audioID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
}
return result
}
func (c *cardConverter) convertVideo(prop cardObj, _ string) string {
result := "🎬 Video"
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
if c.mode == cardModeDetailed {
fileID, _ := prop["fileID"].(string)
if fileID == "" {
fileID, _ = prop["videoID"].(string)
}
if fileID != "" {
result += "(key:" + fileID + ")"
}
}
return result
}
@@ -1455,14 +1323,9 @@ func (c *cardConverter) convertTable(prop cardObj) string {
func (c *cardConverter) extractTableCellValue(data interface{}) string {
switch v := data.(type) {
case string:
// Lark API serialises array-type cell data as a Go-format string like
// "[map[text:VIP] map[text:Premium]]". Detect and extract text values.
if texts := goMapArrayTexts(v); len(texts) > 0 {
return strings.Join(texts, ", ")
}
return v
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
return strconv.FormatFloat(v, 'f', 2, 64)
case []interface{}:
var texts []string
for _, item := range v {
@@ -1483,47 +1346,6 @@ func (c *cardConverter) extractTableCellValue(data interface{}) string {
}
}
// goMapNextKey matches the start of the next key in a Go fmt map literal (space + identifier + colon).
var goMapNextKey = regexp.MustCompile(` [a-zA-Z_][a-zA-Z0-9_]*:`)
// goMapArrayTexts extracts "text" values from a Go-format slice-of-maps string,
// e.g. "[map[text:VIP] map[text:Premium]]" → ["VIP", "Premium"].
// Values may contain spaces; they are delimited by the next map key or by "]".
// Returns nil if the string doesn't look like this format.
func goMapArrayTexts(s string) []string {
if !strings.HasPrefix(s, "[") || !strings.Contains(s, "map[") {
return nil
}
const key = "text:"
var texts []string
rest := s
for {
idx := strings.Index(rest, key)
if idx < 0 {
break
}
after := rest[idx+len(key):]
bracketEnd := strings.Index(after, "]")
nextKey := goMapNextKey.FindStringIndex(after)
var end int
if nextKey != nil && (bracketEnd < 0 || nextKey[0] < bracketEnd) {
end = nextKey[0]
} else if bracketEnd >= 0 {
end = bracketEnd
} else {
if after != "" {
texts = append(texts, after)
}
break
}
if val := after[:end]; val != "" {
texts = append(texts, val)
}
rest = after[end:]
}
return texts
}
func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
if userID == "" {
@@ -1537,14 +1359,14 @@ func (c *cardConverter) convertPerson(prop cardObj, _ string) string {
}
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
}
return personName
return "@" + personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("user(open_id:%s)", userID)
return fmt.Sprintf("@user(open_id:%s)", userID)
}
return userID
return "@" + userID
}
// convertPersonV1 handles the v1 card schema person element.
@@ -1560,14 +1382,14 @@ func (c *cardConverter) convertPersonV1(prop cardObj, _ string) string {
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("%s(open_id:%s)", personName, userID)
return fmt.Sprintf("@%s(open_id:%s)", personName, userID)
}
return personName
return "@" + personName
}
if c.mode == cardModeDetailed {
return fmt.Sprintf("user(open_id:%s)", userID)
return fmt.Sprintf("@user(open_id:%s)", userID)
}
return userID
return "@" + userID
}
func (c *cardConverter) convertPersonList(prop cardObj) string {
@@ -1582,21 +1404,10 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
continue
}
personID, _ := pm["id"].(string)
personName := c.lookupPersonName(personID)
if personName != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("%s(open_id:%s)", personName, personID))
} else {
names = append(names, personName)
}
} else if personID != "" {
if c.mode == cardModeDetailed {
names = append(names, fmt.Sprintf("user(id:%s)", personID))
} else {
names = append(names, personID)
}
if c.mode == cardModeDetailed && personID != "" {
names = append(names, fmt.Sprintf("@user(id:%s)", personID))
} else {
names = append(names, "user")
names = append(names, "@user")
}
}
return strings.Join(names, ", ")
@@ -1604,15 +1415,8 @@ func (c *cardConverter) convertPersonList(prop cardObj) string {
func (c *cardConverter) convertAvatar(prop cardObj, _ string) string {
userID, _ := prop["userID"].(string)
personName := c.lookupPersonName(userID)
if personName != "" {
if c.mode == cardModeDetailed {
return fmt.Sprintf("👤 %s(open_id:%s)", personName, userID)
}
return "👤 " + personName
}
result := "👤"
if userID != "" {
if c.mode == cardModeDetailed && userID != "" {
result += "(id:" + userID + ")"
}
return result
@@ -1693,37 +1497,20 @@ func (c *cardConverter) lookupPersonName(userID string) string {
return ""
}
// lookupOptionUserName resolves a user display name from the attachment's option_users map,
// used for person-selector option labels.
func (c *cardConverter) lookupOptionUserName(userID string) string {
func (c *cardConverter) getImageToken(imageID string) string {
if c.attachment == nil {
return ""
}
if optUsers, ok := c.attachment["option_users"].(cardObj); ok {
if userInfo, ok := optUsers[userID].(cardObj); ok {
if content, ok := userInfo["content"].(string); ok {
return content
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
if token, ok := imageInfo["token"].(string); ok {
return token
}
}
}
return ""
}
// getImageKeyAndToken returns the origin_key and token for an image ID from the attachment map.
// origin_key takes priority over token as the display-ready image reference.
func (c *cardConverter) getImageKeyAndToken(imageID string) (originKey, token string) {
if c.attachment == nil {
return "", ""
}
if images, ok := c.attachment["images"].(cardObj); ok {
if imageInfo, ok := images[imageID].(cardObj); ok {
originKey, _ = imageInfo["origin_key"].(string)
token, _ = imageInfo["token"].(string)
}
}
return originKey, token
}
type cardTextStyle struct {
bold bool
italic bool

File diff suppressed because it is too large Load Diff

View File

@@ -2620,45 +2620,3 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
}
return nil
}
// validateMessageIDs parses and validates the existing +messages comma-separated
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
// locally. It intentionally does not enforce the server-side single-call limit:
// fetchFullMessages chunks backend requests into batches of 20.
func validateMessageIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if part != id {
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateBatchGetMessageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateBatchGetMessageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
}
decoded, err := base64.URLEncoding.DecodeString(id)
if err != nil || len(decoded) == 0 {
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
}
return nil
}

View File

@@ -6,6 +6,7 @@ package mail
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -18,8 +19,7 @@ type mailMessagesOutput struct {
}
// MailMessages is the `+messages` shortcut: batch-fetch full content for
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
// up to 20 message IDs in a single call, preserving request order.
var MailMessages = common.Shortcut{
Service: "mail",
Command: "+messages",
@@ -35,15 +35,11 @@ var MailMessages = common.Shortcut{
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateBotMailboxNotMe(runtime); err != nil {
return err
}
_, err := validateMessageIDs(runtime.Str("message-ids"))
return err
return validateBotMailboxNotMe(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
messageIDs, _ := validateMessageIDs(runtime.Str("message-ids"))
messageIDs := splitByComma(runtime.Str("message-ids"))
body := map[string]interface{}{
"format": messageGetFormat(runtime.Bool("html")),
"message_ids": []string{"<message_id_1>", "<message_id_2>"},
@@ -63,9 +59,9 @@ var MailMessages = common.Shortcut{
}
mailboxID := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailboxID)
messageIDs, err := validateMessageIDs(runtime.Str("message-ids"))
if err != nil {
return err
messageIDs := splitByComma(runtime.Str("message-ids"))
if len(messageIDs) == 0 {
return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
}
html := runtime.Bool("html")

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 21)
for i := range ids {
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[:20]),
Body: batchGetMessagesResponse(ids[:20]),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[20:]),
Body: batchGetMessagesResponse(ids[20:]),
})
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := decodeShortcutEnvelopeData(t, stdout)
if got := int(out["total"].(float64)); got != len(ids) {
t.Fatalf("total = %d, want %d; stdout=%s", got, len(ids), stdout.String())
}
messages, ok := out["messages"].([]interface{})
if !ok {
t.Fatalf("messages has unexpected type %T", out["messages"])
}
if len(messages) != len(ids) {
t.Fatalf("messages length = %d, want %d", len(messages), len(ids))
}
for i, item := range messages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("messages[%d] has unexpected type %T", i, item)
}
if got := msg["message_id"]; got != ids[i] {
t.Fatalf("messages[%d].message_id = %v, want %s", i, got, ids[i])
}
}
}
func requestMessageIDsEqual(want []string) func([]byte) bool {
return func(body []byte) bool {
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
return reflect.DeepEqual(payload.MessageIDs, want)
}
}
func batchGetMessagesResponse(ids []string) map[string]interface{} {
messages := make([]map[string]interface{}, 0, len(ids))
for _, id := range ids {
messages = append(messages, map[string]interface{}{
"message_id": id,
"subject": id,
})
}
return map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": messages,
},
}
}

View File

@@ -4,7 +4,6 @@
package mail
import (
"encoding/base64"
"os"
"strings"
"testing"
@@ -134,7 +133,7 @@ func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--message-ids", validMessageIDForTest("biz-x"),
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
@@ -143,7 +142,7 @@ func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", validMessageIDForTest("biz-x"),
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
}, f, stdout)
assertValidatePasses(t, err)
}
@@ -183,87 +182,3 @@ func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
}, f, stdout)
assertValidatePasses(t, err)
}
// --- message_ids validation tests (S2) ---
func validMessageIDForTest(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s))
}
func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
if err != nil {
t.Fatalf("expected nil error for valid IDs, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
_, err := validateMessageIDs("")
assertValidationError(t, err, "--message-ids is required")
_, err = validateMessageIDs(" ")
assertValidationError(t, err, "--message-ids is required")
}
func TestValidateMessageIDsAcceptsMoreThanSingleBackendBatch(t *testing.T) {
ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('a' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for more than one backend batch, got: %v", err)
}
}
func TestValidateMessageIDsRejectsEmptyEntry(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-1") + ",," + validMessageIDForTest("biz-2"))
assertValidationError(t, err, "entry 2 is empty")
}
func TestValidateMessageIDsRejectsLeadingOrTrailingWhitespace(t *testing.T) {
id1 := validMessageIDForTest("biz-1")
id2 := validMessageIDForTest("biz-2")
_, err := validateMessageIDs(id1 + ", " + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
_, err = validateMessageIDs(" " + id1 + "," + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
}
func TestValidateMessageIDsRejectsDuplicateIDs(t *testing.T) {
id := validMessageIDForTest("biz-1")
_, err := validateMessageIDs(id + "," + id)
assertValidationError(t, err, "duplicate message ID is not allowed")
}
func TestValidateMessageIDsRejectsJSONLikeInput(t *testing.T) {
_, err := validateMessageIDs(`["id1","id2"]`)
assertValidationError(t, err, "expected a base64url")
}
func TestValidateMessageIDsRejectsColonJoinedInput(t *testing.T) {
_, err := validateMessageIDs("id1:id2")
assertValidationError(t, err, "expected a base64url")
}
func TestValidateMessageIDsRejectsNumericPrimaryID(t *testing.T) {
_, err := validateMessageIDs("123456789")
assertValidationError(t, err, "numeric primary IDs are not supported")
}
func TestValidateMessageIDsAcceptsExactlyTwenty(t *testing.T) {
ids := make([]string, 20)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('A' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for exactly 20 IDs, got: %v", err)
}
}
func TestValidateMessageIDRejectsInvalidBase64(t *testing.T) {
_, err := validateMessageIDs("msg 1")
assertValidationError(t, err, "expected a base64url")
_, err = validateMessageIDs("not-base64!")
assertValidationError(t, err, "expected a base64url")
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/apps"
@@ -30,7 +29,6 @@ import (
"github.com/larksuite/cli/shortcuts/markdown"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
sheetsbackward "github.com/larksuite/cli/shortcuts/sheets/backward"
"github.com/larksuite/cli/shortcuts/slides"
"github.com/larksuite/cli/shortcuts/task"
"github.com/larksuite/cli/shortcuts/vc"
@@ -55,40 +53,55 @@ func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool {
return slices.Contains(allowed, brand)
}
// allShortcuts aggregates shortcuts from all domain packages.
var allShortcuts []common.Shortcut
// allShortcuts aggregates shortcuts from all domain packages. Each entry is a
// Mountable: both legacy common.Shortcut (via *Shortcut after the descriptor
// accessor methods landed) and the new generic common.TypedShortcut[T] satisfy
// it. The slice element type is ShortcutDescriptor so read-only consumers
// (auth login, scope hint, shortcuts.json generator) can read metadata without
// caring about which concrete implementation backs each entry.
//
// Only legacy shortcuts are registered today; the typed track stays wired
// (ShortcutDescriptor / Mountable dispatch + buildTypedHelp) but currently has
// no domain caller.
var allShortcuts []common.ShortcutDescriptor
func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...)
allShortcuts = append(allShortcuts, im.Shortcuts()...)
allShortcuts = append(allShortcuts, contact_shortcuts.Shortcuts()...)
allShortcuts = append(allShortcuts, sheets.Shortcuts()...)
// Backward-compatible sheets shortcuts (pre-refactor command names),
// kept under shortcuts/sheets/backward so external callers relying on the
// old `+create`, `+read`, `+write`, ... commands keep working alongside the
// refactored ones. Command names are disjoint from sheets.Shortcuts().
allShortcuts = append(allShortcuts, wrapSheetsBackwardDeprecation(sheetsbackward.Shortcuts())...)
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, markdown.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
// addLegacy boxes a legacy []common.Shortcut into the descriptor slice. We use
// pointer-valued elements because the pointer-receiver scope methods
// (ScopesForIdentity / ConditionalScopesForIdentity / DeclaredScopesForIdentity)
// are required by ShortcutDescriptor — value receivers don't satisfy them.
func addLegacy(list []common.Shortcut) {
for i := range list {
allShortcuts = append(allShortcuts, &list[i])
}
}
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
func init() {
addLegacy(apps.Shortcuts())
addLegacy(calendar.Shortcuts())
addLegacy(doc.Shortcuts())
addLegacy(drive.Shortcuts())
addLegacy(im.Shortcuts())
addLegacy(contact_shortcuts.Shortcuts())
addLegacy(sheets.Shortcuts())
addLegacy(base.Shortcuts())
addLegacy(event.Shortcuts())
addLegacy(mail.Shortcuts())
addLegacy(markdown.Shortcuts())
addLegacy(slides.Shortcuts())
addLegacy(minutes.Shortcuts())
addLegacy(task.Shortcuts())
addLegacy(vc.Shortcuts())
addLegacy(whiteboard.Shortcuts())
addLegacy(wiki.Shortcuts())
addLegacy(okr.Shortcuts())
}
// AllShortcuts returns a copy of all registered shortcut descriptors (for
// dump-shortcuts and auth/scope-hint consumers).
//
//go:noinline
func AllShortcuts() []common.Shortcut {
return append([]common.Shortcut(nil), allShortcuts...)
func AllShortcuts() []common.ShortcutDescriptor {
return append([]common.ShortcutDescriptor(nil), allShortcuts...)
}
// RegisterShortcuts registers all +shortcut commands on the program.
@@ -105,10 +118,15 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
}
}
// Group by service
byService := make(map[string][]common.Shortcut)
for _, s := range allShortcuts {
byService[s.Service] = append(byService[s.Service], s)
// Group by service. Each entry is a Mountable so MountWithContext works
// uniformly across legacy *Shortcut and TypedShortcut[T].
byService := make(map[string][]common.Mountable)
for _, d := range allShortcuts {
m, ok := d.(common.Mountable)
if !ok {
panic(fmt.Sprintf("shortcut %s/%s missing Mountable", d.GetService(), d.GetCommand()))
}
byService[d.GetService()] = append(byService[d.GetService()], m)
}
for service, shortcuts := range byService {
@@ -147,15 +165,12 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f
doc.ConfigureServiceHelp(svc)
}
for _, shortcut := range shortcuts {
shortcut.MountWithContext(ctx, svc, f)
for _, m := range shortcuts {
m.MountWithContext(ctx, svc, f)
}
if service == "mail" {
mail.InstallOnMail(svc)
}
if service == "sheets" {
applySheetsCompatGroups(svc)
}
if !IsShortcutServiceAvailable(service, brand) {
installBrandRestrictionGuard(svc, service, brand)
@@ -199,153 +214,3 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
// --help bypasses RunE, so surface the restriction in Long too.
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
}
// Sheets backward-compatibility help grouping.
//
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that
// users whose lark-sheets skill predates the refactor keep working even after
// upgrading only the binary. In `sheets --help` those aliases would otherwise
// sort alphabetically into the same flat list as the current commands,
// indistinguishable from them. applySheetsCompatGroups splits them into a
// dedicated cobra group whose heading tells the user to update their skill, and
// appends a "(→ +new-command)" pointer to each alias so the migration target is
// obvious. Pure presentation — the aliases stay fully executable.
const (
sheetsCurrentGroupID = "sheets-current"
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
// `sheets --help` grouping and the generic unknown-subcommand path
// (cmd/root.go) classify these aliases the same way.
sheetsDeprecatedGroupID = cmdutil.DeprecatedGroupID
)
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current
// command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
// absent from this map still land in the deprecated group, just without a
// pointer, so a missing entry degrades gracefully rather than misgrouping.
var sheetsAliasReplacement = map[string]string{
// spreadsheet / sheet management
"+create": "+workbook-create",
"+info": "+workbook-info",
"+export": "+workbook-export",
"+create-sheet": "+sheet-create",
"+copy-sheet": "+sheet-copy",
"+delete-sheet": "+sheet-delete",
"+update-sheet": "+sheet-rename / +sheet-move / …",
// cell data
"+read": "+cells-get",
"+write": "+cells-set",
"+append": "+cells-set",
"+find": "+cells-search",
"+replace": "+cells-replace",
// cell style / merge / image
"+set-style": "+cells-set-style",
"+batch-set-style": "+cells-batch-set-style",
"+merge-cells": "+cells-merge",
"+unmerge-cells": "+cells-unmerge",
"+write-image": "+cells-set-image",
// row / column dimensions
"+add-dimension": "+dim-insert",
"+insert-dimension": "+dim-insert",
"+update-dimension": "+rows-resize / +dim-hide / …",
"+move-dimension": "+dim-move",
"+delete-dimension": "+dim-delete",
// filter views (conditions folded into the view flags)
"+create-filter-view": "+filter-view-create",
"+update-filter-view": "+filter-view-update",
"+list-filter-views": "+filter-view-list",
"+get-filter-view": "+filter-view-list",
"+delete-filter-view": "+filter-view-delete",
"+create-filter-view-condition": "+filter-view-update",
"+update-filter-view-condition": "+filter-view-update",
"+list-filter-view-conditions": "+filter-view-list",
"+get-filter-view-condition": "+filter-view-list",
"+delete-filter-view-condition": "+filter-view-update",
// dropdowns
"+set-dropdown": "+dropdown-set",
"+update-dropdown": "+dropdown-update",
"+get-dropdown": "+dropdown-get",
"+delete-dropdown": "+dropdown-delete",
// float images (media-upload folded into create)
"+media-upload": "+float-image-create",
"+create-float-image": "+float-image-create",
"+update-float-image": "+float-image-update",
"+get-float-image": "+float-image-list",
"+list-float-images": "+float-image-list",
"+delete-float-image": "+float-image-delete",
}
func applySheetsCompatGroups(svc *cobra.Command) {
svc.AddGroup(
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
&cobra.Group{
ID: sheetsDeprecatedGroupID,
Title: "Deprecated pre-refactor commands (still work) — update your lark-sheets skill, then: lark-cli update",
},
)
deprecated := make(map[string]struct{})
for _, s := range sheetsbackward.Shortcuts() {
deprecated[s.Command] = struct{}{}
}
for _, c := range svc.Commands() {
name := c.Name()
if _, ok := deprecated[name]; ok {
c.GroupID = sheetsDeprecatedGroupID
if repl := sheetsAliasReplacement[name]; repl != "" {
c.Short = c.Short + " (→ " + repl + ")"
}
continue
}
// Only the refactored shortcuts (all "+"-prefixed) belong in the current
// group. Leave the OpenAPI metaapi subcommands (spreadsheets, ...) and the
// auto-added help/completion ungrouped so cobra files them under
// "Additional Commands".
if len(name) > 0 && name[0] == '+' {
c.GroupID = sheetsCurrentGroupID
}
}
}
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets
// alias so that invoking it records a process-level deprecation notice, which
// cmd/root.go surfaces in the JSON "_notice" envelope. This reaches the users
// the --help grouping cannot: those whose pre-refactor skill calls +read /
// +write directly and never reads --help. Replacement targets come from
// sheetsAliasReplacement — the same single source of truth that drives the
// "(→ +new)" help pointers.
func wrapSheetsBackwardDeprecation(list []common.Shortcut) []common.Shortcut {
for i := range list {
notice := &deprecation.Notice{
Command: list[i].Command,
Replacement: sheetsAliasReplacement[list[i].Command],
Skill: "lark-sheets",
}
// Record the notice as soon as the command's own logic runs, so it is
// surfaced even when Validate rejects the call — an out-of-date skill
// can pass pre-refactor argument shapes (e.g. a range without the new
// sheet-id prefix) and fail validation before Execute — and when
// --dry-run short-circuits before Execute. Both hooks store the same
// pointer, so setting it twice is harmless.
if origValidate := list[i].Validate; origValidate != nil {
list[i].Validate = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origValidate(ctx, runtime)
}
}
if origExecute := list[i].Execute; origExecute != nil {
list[i].Execute = func(ctx context.Context, runtime *common.RuntimeContext) error {
deprecation.SetPending(notice)
return origExecute(ctx, runtime)
}
}
// The Validate/Execute wrappers above miss one path: a cobra-level
// required flag (MarkFlagRequired) that is absent fails at
// ValidateRequiredFlags, before RunE — so neither hook runs and the
// notice would be lost on exactly the "stale skill calls the old command
// and mis-supplies flags" case it exists for. OnInvoke runs from PreRunE,
// ahead of ValidateRequiredFlags, so the notice still surfaces there.
list[i].OnInvoke = func() { deprecation.SetPending(notice) }
}
return list
}

View File

@@ -5,7 +5,6 @@ package shortcuts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -17,7 +16,6 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
@@ -50,10 +48,16 @@ func newRegisterTestProgramWithTipsHelp() *cobra.Command {
}
func TestAllShortcutsScopesNotNil(t *testing.T) {
for _, s := range allShortcuts {
hasScopes := s.Scopes != nil || s.UserScopes != nil || s.BotScopes != nil
if !hasScopes {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", s.Service, s.Command)
for _, d := range allShortcuts {
legacy, ok := d.(*common.Shortcut)
if !ok {
// Typed shortcuts enforce scope-presence at their own test layer
// (TypedShortcut[T] integration tests); the descriptor interface
// doesn't expose the raw fields needed for the nil-vs-empty check.
continue
}
if legacy.Scopes == nil && legacy.UserScopes == nil && legacy.BotScopes == nil {
t.Errorf("shortcut %s/%s: Scopes is nil (must be explicitly set, use []string{} if no scopes needed)", legacy.Service, legacy.Command)
}
}
}
@@ -65,8 +69,8 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
}
hasBaseGet := false
for _, shortcut := range shortcuts {
if shortcut.Service == "base" && shortcut.Command == "+base-get" {
for _, d := range shortcuts {
if d.GetService() == "base" && d.GetCommand() == "+base-get" {
hasBaseGet = true
break
}
@@ -75,9 +79,29 @@ func TestAllShortcutsReturnsCopyAndIncludesBase(t *testing.T) {
t.Fatal("AllShortcuts does not include base/+base-get")
}
shortcuts[0].Service = "mutated"
if AllShortcuts()[0].Service == "mutated" {
t.Fatal("AllShortcuts should return a copy")
// Returned slice is a defensive copy of the descriptor references;
// appending to it must not affect the canonical registry. (Element
// identity is shared by design — mutation through pointers is the
// caller's responsibility to avoid, same as any interface slice.)
got := AllShortcuts()
got = append(got, &common.Shortcut{Service: "synthetic"})
if len(AllShortcuts()) >= len(got) {
t.Fatal("AllShortcuts should return a copy that callers can append to without affecting the registry")
}
}
func TestAllShortcuts_ReturnsShortcutDescriptors(t *testing.T) {
list := AllShortcuts()
if len(list) == 0 {
t.Fatal("AllShortcuts returned empty slice")
}
for _, d := range list {
if d.GetService() == "" {
t.Errorf("shortcut missing service: %T", d)
}
if d.GetCommand() == "" {
t.Errorf("shortcut %q missing command", d.GetService())
}
}
}
@@ -454,10 +478,10 @@ func TestGenerateShortcutsJSON(t *testing.T) {
}
grouped := make(map[string][]entry)
for _, s := range shortcuts {
verb := strings.TrimPrefix(s.Command, "+")
grouped[s.Service] = append(grouped[s.Service], entry{
verb := strings.TrimPrefix(s.GetCommand(), "+")
grouped[s.GetService()] = append(grouped[s.GetService()], entry{
Verb: verb,
Description: s.Description,
Description: s.GetDescription(),
Scopes: s.DeclaredScopesForIdentity("user"),
})
}
@@ -474,152 +498,3 @@ func TestGenerateShortcutsJSON(t *testing.T) {
}
t.Logf("wrote %d bytes to %s", len(data), output)
}
// applySheetsCompatGroups must split the sheets service into a current group
// (refactored "+"-shortcuts) and a deprecated group (backward-compat aliases),
// append a "(→ +new)" migration pointer to each alias, and leave non-"+"
// subcommands (OpenAPI metaapi, help/completion) ungrouped so cobra files them
// under "Additional Commands".
func TestApplySheetsCompatGroups(t *testing.T) {
svc := &cobra.Command{Use: "sheets"}
newCmd := &cobra.Command{Use: "+cells-get", Short: "Read ranges"}
aliasCmd := &cobra.Command{Use: "+read", Short: "Read spreadsheet cell values"}
metaCmd := &cobra.Command{Use: "spreadsheets", Short: "spreadsheets operations"}
svc.AddCommand(newCmd, aliasCmd, metaCmd)
applySheetsCompatGroups(svc)
if !svc.ContainsGroup(sheetsCurrentGroupID) {
t.Errorf("current group %q not registered", sheetsCurrentGroupID)
}
if !svc.ContainsGroup(sheetsDeprecatedGroupID) {
t.Errorf("deprecated group %q not registered", sheetsDeprecatedGroupID)
}
if newCmd.GroupID != sheetsCurrentGroupID {
t.Errorf("+cells-get GroupID = %q, want %q", newCmd.GroupID, sheetsCurrentGroupID)
}
if aliasCmd.GroupID != sheetsDeprecatedGroupID {
t.Errorf("+read GroupID = %q, want %q", aliasCmd.GroupID, sheetsDeprecatedGroupID)
}
if !strings.Contains(aliasCmd.Short, "(→ +cells-get)") {
t.Errorf("+read Short missing migration pointer, got %q", aliasCmd.Short)
}
if metaCmd.GroupID != "" {
t.Errorf("metaapi spreadsheets should stay ungrouped, got GroupID %q", metaCmd.GroupID)
}
}
// End-to-end: the rendered `sheets --help` must surface the deprecated-group
// heading (telling users to update their skill) plus the per-alias migration
// pointers, while keeping the refactored shortcuts under Available Commands.
func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
sheetsCmd, _, err := program.Find([]string{"sheets"})
if err != nil {
t.Fatalf("find sheets command: %v", err)
}
var out bytes.Buffer
sheetsCmd.SetOut(&out)
if err := sheetsCmd.Help(); err != nil {
t.Fatalf("sheets help failed: %v", err)
}
got := out.String()
for _, want := range []string{
"Available Commands:",
"Deprecated pre-refactor commands",
"update your lark-sheets skill",
"+read",
"(→ +cells-get)",
"+write",
"(→ +cells-set)",
} {
if !strings.Contains(got, want) {
t.Fatalf("sheets help missing %q:\n%s", want, got)
}
}
}
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that
// invoking it records a process-level deprecation notice (reusing
// sheetsAliasReplacement for the migration target) while still calling the
// original Execute. cmd/root.go reads that notice into the JSON "_notice".
func TestWrapSheetsBackwardDeprecation(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
called := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+read",
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
called = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if len(out) != 1 {
t.Fatalf("wrapped list len = %d, want 1", len(out))
}
if deprecation.GetPending() != nil {
t.Fatal("notice set before wrapped Execute ran")
}
if err := out[0].Execute(context.Background(), nil); err != nil {
t.Fatalf("wrapped Execute returned error: %v", err)
}
if !called {
t.Fatal("original Execute was not invoked by the wrapper")
}
dep := deprecation.GetPending()
if dep == nil {
t.Fatal("expected a pending deprecation notice after Execute")
}
if dep.Command != "+read" {
t.Errorf("notice Command = %q, want +read", dep.Command)
}
if dep.Replacement != "+cells-get" {
t.Errorf("notice Replacement = %q, want +cells-get (from sheetsAliasReplacement)", dep.Replacement)
}
if dep.Skill != "lark-sheets" {
t.Errorf("notice Skill = %q, want lark-sheets", dep.Skill)
}
}
// The wrapper must also decorate Validate, so an out-of-date skill whose
// pre-refactor argument shape fails validation (before Execute) still gets the
// deprecation notice in its error envelope.
func TestWrapSheetsBackwardDeprecationValidateHook(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
validated := false
in := []common.Shortcut{{
Service: "sheets",
Command: "+write",
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
validated = true
return nil
},
}}
out := wrapSheetsBackwardDeprecation(in)
if out[0].Validate == nil {
t.Fatal("Validate hook was dropped by the wrapper")
}
if err := out[0].Validate(context.Background(), nil); err != nil {
t.Fatalf("wrapped Validate returned error: %v", err)
}
if !validated {
t.Fatal("original Validate was not invoked")
}
dep := deprecation.GetPending()
if dep == nil || dep.Command != "+write" || dep.Replacement != "+cells-set" {
t.Fatalf("Validate hook did not record expected notice: %#v", dep)
}
}

View File

@@ -1,239 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
singleCellRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*$`)
cellSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+[1-9][0-9]*$`)
cellToColRangePattern = regexp.MustCompile(`^[A-Za-z]+[1-9][0-9]*:[A-Za-z]+$`)
colSpanRangePattern = regexp.MustCompile(`^[A-Za-z]+:[A-Za-z]+$`)
rowSpanRangePattern = regexp.MustCompile(`^[1-9][0-9]*:[1-9][0-9]*$`)
cellRefPattern = regexp.MustCompile(`^([A-Za-z]+)([1-9][0-9]*)$`)
)
var sheetRangeSeparatorReplacer = strings.NewReplacer(`\`, "!", `\!`, "!", "", "!")
// getFirstSheetID queries the spreadsheet and returns the first sheet's ID.
func getFirstSheetID(runtime *common.RuntimeContext, spreadsheetToken string) (string, error) {
data, err := runtime.CallAPI("GET", fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/query", validate.EncodePathSegment(spreadsheetToken)), nil, nil)
if err != nil {
return "", err
}
sheets, _ := data["sheets"].([]interface{})
if len(sheets) > 0 {
sheet, _ := sheets[0].(map[string]interface{})
if id, ok := sheet["sheet_id"].(string); ok && id != "" {
return id, nil
}
}
return "", output.Errorf(output.ExitAPI, "not_found", "no sheets found in this spreadsheet")
}
// extractSpreadsheetToken extracts spreadsheet token from URL.
func extractSpreadsheetToken(input string) string {
input = strings.TrimSpace(input)
prefixes := []string{"/sheets/", "/spreadsheets/"}
for _, prefix := range prefixes {
if idx := strings.Index(input, prefix); idx >= 0 {
token := input[idx+len(prefix):]
if idx2 := strings.IndexAny(token, "/?#"); idx2 >= 0 {
token = token[:idx2]
}
return token
}
}
return input
}
func normalizeSheetRange(sheetID, input string) string {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID == "" {
return input
}
if looksLikeRelativeRange(input) {
return sheetID + "!" + input
}
return input
}
func normalizePointRange(sheetID, input string) string {
input = normalizeSheetRange(sheetID, input)
if input == "" {
return input
}
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok || !singleCellRangePattern.MatchString(subRange) {
return input
}
return rangeSheetID + "!" + subRange + ":" + subRange
}
func normalizeWriteRange(sheetID, input string, values interface{}) string {
rows, cols := matrixDimensions(values)
input = normalizeSheetRangeSeparators(input)
if input == "" {
return buildRectRange(sheetID, "A1", rows, cols)
}
input = normalizeSheetRange(sheetID, input)
rangeSheetID, subRange, ok := splitSheetRange(input)
if !ok {
return buildRectRange(input, "A1", rows, cols)
}
if singleCellRangePattern.MatchString(subRange) {
return buildRectRange(rangeSheetID, subRange, rows, cols)
}
return input
}
func validateSheetRangeInput(sheetID, input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" || strings.Contains(input, "!") || sheetID != "" {
return nil
}
if looksLikeRelativeRange(input) {
return common.FlagErrorf("--range %q requires --sheet-id or a <sheetId>! prefix", input)
}
return nil
}
// validateSingleCellRange rejects multi-cell spans (e.g. "A1:B2") that are
// invalid for single-cell operations like write-image. Empty and single-cell
// values pass through.
func validateSingleCellRange(input string) error {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return nil
}
// Extract the sub-range after the sheet ID prefix, if present.
subRange := input
if _, sr, ok := splitSheetRange(input); ok {
subRange = sr
}
if cellSpanRangePattern.MatchString(subRange) {
parts := strings.SplitN(subRange, ":", 2)
if strings.EqualFold(parts[0], parts[1]) {
return nil
}
return common.FlagErrorf("--range %q must be a single cell (e.g. A1 or A1:A1), got a multi-cell span", input)
}
return nil
}
func looksLikeRelativeRange(input string) bool {
input = normalizeSheetRangeSeparators(input)
if input == "" {
return false
}
return singleCellRangePattern.MatchString(input) ||
cellSpanRangePattern.MatchString(input) ||
cellToColRangePattern.MatchString(input) ||
colSpanRangePattern.MatchString(input) ||
rowSpanRangePattern.MatchString(input)
}
func splitSheetRange(input string) (sheetID, subRange string, ok bool) {
parts := strings.SplitN(normalizeSheetRangeSeparators(input), "!", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}
func normalizeSheetRangeSeparators(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return input
}
return sheetRangeSeparatorReplacer.Replace(input)
}
func buildRectRange(sheetID, anchor string, rows, cols int) string {
if sheetID == "" {
return ""
}
if rows < 1 {
rows = 1
}
if cols < 1 {
cols = 1
}
endCell, err := offsetCell(anchor, rows-1, cols-1)
if err != nil {
return sheetID
}
return sheetID + "!" + anchor + ":" + endCell
}
func matrixDimensions(values interface{}) (rows, cols int) {
rowList, ok := values.([]interface{})
if !ok || len(rowList) == 0 {
return 1, 1
}
rows = len(rowList)
for _, row := range rowList {
if cells, ok := row.([]interface{}); ok && len(cells) > cols {
cols = len(cells)
}
}
if cols == 0 {
cols = 1
}
return rows, cols
}
func offsetCell(cell string, rowOffset, colOffset int) (string, error) {
matches := cellRefPattern.FindStringSubmatch(strings.TrimSpace(cell))
if len(matches) != 3 {
return "", fmt.Errorf("invalid cell reference: %s", cell)
}
colIndex := columnNameToIndex(matches[1])
if colIndex < 1 {
return "", fmt.Errorf("invalid column: %s", matches[1])
}
rowIndex, err := strconv.Atoi(matches[2])
if err != nil {
return "", err
}
return fmt.Sprintf("%s%d", columnIndexToName(colIndex+colOffset), rowIndex+rowOffset), nil
}
func columnNameToIndex(name string) int {
name = strings.ToUpper(strings.TrimSpace(name))
if name == "" {
return 0
}
index := 0
for _, r := range name {
if r < 'A' || r > 'Z' {
return 0
}
index = index*26 + int(r-'A'+1)
}
return index
}
func columnIndexToName(index int) string {
if index < 1 {
return ""
}
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
return string(out)
}

View File

@@ -1,71 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package backward
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all sheets shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
// Spreadsheet management
SheetCreate,
SheetInfo,
SheetExport,
// Sheet management
SheetCreateSheet,
SheetCopySheet,
SheetDeleteSheet,
SheetUpdateSheet,
// Cell data
SheetRead,
SheetWrite,
SheetAppend,
SheetFind,
SheetReplace,
// Cell style and merge
SheetSetStyle,
SheetBatchSetStyle,
SheetMergeCells,
SheetUnmergeCells,
// Cell images
SheetWriteImage,
// Row/column management
SheetAddDimension,
SheetInsertDimension,
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
// Filter views
SheetCreateFilterView,
SheetUpdateFilterView,
SheetListFilterViews,
SheetGetFilterView,
SheetDeleteFilterView,
SheetCreateFilterViewCondition,
SheetUpdateFilterViewCondition,
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
// Dropdown
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
// Float images
SheetMediaUpload,
SheetCreateFloatImage,
SheetUpdateFloatImage,
SheetGetFloatImage,
SheetListFloatImages,
SheetDeleteFloatImage,
}
}

View File

@@ -1,907 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestBatchOp_BodyMatchesStandalone is the core contract: for every batchable
// shortcut, the MCP body produced inside +batch-update must be byte-for-byte
// identical to the body the same shortcut produces when invoked standalone
// (both observed via --dry-run, comparing tool_name + decoded input). This is
// what guarantees "a sub-op behaves exactly like the standalone command", and
// it is the regression guard for the whole flag→body translator reuse.
//
// Each case provides the standalone CLI args and the equivalent sub-op input
// object (same CLI flag names, minus the spreadsheet locator which the batch
// supplies at the top level).
func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
t.Parallel()
cases := []struct {
shortcut string
sc common.Shortcut
// standalone args (excluding --url, which every case shares)
args []string
// sub-op input object as JSON (CLI flag names; no excel_id/url)
subInput string
}{
{
shortcut: "+cells-set",
sc: CellsSet,
args: []string{"--sheet-id", "sh1", "--range", "A1:B1", "--cells", `[[{"value":"x"},{"value":"y"}]]`},
subInput: `{"sheet-id":"sh1","range":"A1:B1","cells":[[{"value":"x"},{"value":"y"}]]}`,
},
{
shortcut: "+cells-clear",
sc: CellsClear,
args: []string{"--sheet-id", "sh1", "--range", "A1:C3", "--scope", "formats"},
subInput: `{"sheet-id":"sh1","range":"A1:C3","scope":"formats"}`,
},
{
shortcut: "+cells-replace",
sc: CellsReplace,
args: []string{"--sheet-id", "sh1", "--find", "foo", "--replacement", "bar", "--match-case"},
subInput: `{"sheet-id":"sh1","find":"foo","replacement":"bar","match-case":true}`,
},
{
shortcut: "+csv-put",
sc: CsvPut,
args: []string{"--sheet-id", "sh1", "--csv", "a,b\n1,2", "--start-cell", "B2"},
subInput: `{"sheet-id":"sh1","csv":"a,b\n1,2","start-cell":"B2"}`,
},
{
shortcut: "+cells-merge",
sc: CellsMerge,
args: []string{"--sheet-id", "sh1", "--range", "A1:C1", "--merge-type", "rows"},
subInput: `{"sheet-id":"sh1","range":"A1:C1","merge-type":"rows"}`,
},
{
shortcut: "+cells-unmerge",
sc: CellsUnmerge,
args: []string{"--sheet-id", "sh1", "--range", "A1:C1"},
subInput: `{"sheet-id":"sh1","range":"A1:C1"}`,
},
{
shortcut: "+dim-insert",
sc: DimInsert,
args: []string{"--sheet-id", "sh1", "--position", "11", "--count", "2", "--inherit-style", "before"},
subInput: `{"sheet-id":"sh1","position":"11","count":2,"inherit-style":"before"}`,
},
{
shortcut: "+dim-delete",
sc: DimDelete,
args: []string{"--sheet-id", "sh1", "--range", "C:D"},
subInput: `{"sheet-id":"sh1","range":"C:D"}`,
},
{
shortcut: "+dim-hide",
sc: DimHide,
args: []string{"--sheet-id", "sh1", "--range", "2:3"},
subInput: `{"sheet-id":"sh1","range":"2:3"}`,
},
{
shortcut: "+dim-freeze",
sc: DimFreeze,
args: []string{"--sheet-id", "sh1", "--dimension", "row", "--count", "2"},
subInput: `{"sheet-id":"sh1","dimension":"row","count":2}`,
},
{
shortcut: "+dim-group",
sc: DimGroup,
args: []string{"--sheet-id", "sh1", "--range", "2:5", "--group-state", "fold"},
subInput: `{"sheet-id":"sh1","range":"2:5","group-state":"fold"}`,
},
{
shortcut: "+rows-resize",
sc: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
},
{
shortcut: "+cols-resize",
sc: ColsResize,
args: []string{"--sheet-id", "sh1", "--range", "B:D", "--type", "standard"},
subInput: `{"sheet-id":"sh1","range":"B:D","type":"standard"}`,
},
{
shortcut: "+range-move",
sc: RangeMove,
args: []string{"--sheet-id", "sh1", "--source-range", "A1:C5", "--target-range", "D1"},
subInput: `{"sheet-id":"sh1","source-range":"A1:C5","target-range":"D1"}`,
},
{
shortcut: "+range-copy",
sc: RangeCopy,
args: []string{"--sheet-id", "sh1", "--source-range", "A1:B2", "--target-range", "A10", "--paste-type", "values"},
subInput: `{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"values"}`,
},
{
shortcut: "+range-fill",
sc: RangeFill,
args: []string{"--sheet-id", "sh1", "--source-range", "A1:A2", "--target-range", "A1:A10", "--series-type", "linear"},
subInput: `{"sheet-id":"sh1","source-range":"A1:A2","target-range":"A1:A10","series-type":"linear"}`,
},
{
shortcut: "+range-sort",
sc: RangeSort,
args: []string{"--sheet-id", "sh1", "--range", "A1:D10", "--sort-keys", `[{"column":"B","ascending":true}]`, "--has-header"},
subInput: `{"sheet-id":"sh1","range":"A1:D10","sort-keys":[{"column":"B","ascending":true}],"has-header":true}`,
},
{
shortcut: "+sheet-create",
sc: SheetCreate,
args: []string{"--title", "New", "--index", "2"},
subInput: `{"title":"New","index":2}`,
},
{
shortcut: "+sheet-delete",
sc: SheetDelete,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-rename",
sc: SheetRename,
args: []string{"--sheet-id", "sh1", "--title", "Renamed"},
subInput: `{"sheet-id":"sh1","title":"Renamed"}`,
},
{
shortcut: "+sheet-copy",
sc: SheetCopy,
args: []string{"--sheet-id", "sh1", "--title", "Copy"},
subInput: `{"sheet-id":"sh1","title":"Copy"}`,
},
{
shortcut: "+sheet-hide",
sc: SheetHide,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-unhide",
sc: SheetUnhide,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+sheet-set-tab-color",
sc: SheetSetTabColor,
args: []string{"--sheet-id", "sh1", "--color", "#FF0000"},
subInput: `{"sheet-id":"sh1","color":"#FF0000"}`,
},
{
shortcut: "+dropdown-set",
sc: DropdownSet,
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--multiple"},
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"multiple":true}`,
},
{
// --highlight=false explicitly opts out of the server's new
// enable_highlight=true default. Covers the tri-state Changed()
// branch in buildDropdownValidation: standalone reads the cobra
// "Changed" bit; sub-op reads the key's presence in the map.
shortcut: "+dropdown-set",
sc: DropdownSet,
args: []string{"--sheet-id", "sh1", "--range", "A2:A4", "--options", `["x","y"]`, "--highlight=false"},
subInput: `{"sheet-id":"sh1","range":"A2:A4","options":["x","y"],"highlight":false}`,
},
{
shortcut: "+chart-create",
sc: ChartCreate,
args: []string{"--sheet-id", "sh1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
subInput: `{"sheet-id":"sh1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
},
{
shortcut: "+chart-update",
sc: ChartUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--properties", `{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`},
subInput: `{"sheet-id":"sh1","chart-id":"c1","properties":{"position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}}`,
},
{
shortcut: "+chart-delete",
sc: ChartDelete,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
},
{
shortcut: "+pivot-create",
sc: PivotCreate,
// +pivot-create renamed --sheet-id / --sheet-name → --target-sheet-id /
// --target-sheet-name to flag the placement-sheet semantics (the data
// source is in --source). Both standalone args and the +batch-update
// sub-op input must use the new names.
args: []string{"--target-sheet-id", "sh1", "--properties", `{"rows":[]}`, "--source", "Sheet1!A1:D100"},
subInput: `{"target-sheet-id":"sh1","properties":{"rows":[]},"source":"Sheet1!A1:D100"}`,
},
{
shortcut: "+cond-format-create",
sc: CondFormatCreate,
args: []string{"--sheet-id", "sh1", "--properties", `{"style":{}}`, "--rule-type", "duplicateValues", "--ranges", `["A1:A100"]`},
subInput: `{"sheet-id":"sh1","properties":{"style":{}},"rule-type":"duplicateValues","ranges":["A1:A100"]}`,
},
{
shortcut: "+filter-create",
sc: FilterCreate,
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
},
{
shortcut: "+filter-update",
sc: FilterUpdate,
args: []string{"--sheet-id", "sh1", "--range", "A1:F1000", "--properties", `{"rules":[]}`},
subInput: `{"sheet-id":"sh1","range":"A1:F1000","properties":{"rules":[]}}`,
},
{
shortcut: "+filter-delete",
sc: FilterDelete,
args: []string{"--sheet-id", "sh1"},
subInput: `{"sheet-id":"sh1"}`,
},
{
shortcut: "+filter-view-create",
sc: FilterViewCreate,
args: []string{"--sheet-id", "sh1", "--range", "A1:Z100", "--view-name", "v1", "--properties", `{"rules":[]}`},
subInput: `{"sheet-id":"sh1","range":"A1:Z100","view-name":"v1","properties":{"rules":[]}}`,
},
{
shortcut: "+sparkline-create",
sc: SparklineCreate,
args: []string{"--sheet-id", "sh1", "--properties", `{"type":"line","data_range":"A2:F2","target_range":"G2"}`},
subInput: `{"sheet-id":"sh1","properties":{"type":"line","data_range":"A2:F2","target_range":"G2"}}`,
},
{
shortcut: "+sparkline-delete",
sc: SparklineDelete,
args: []string{"--sheet-id", "sh1", "--group-id", "g1"},
subInput: `{"sheet-id":"sh1","group-id":"g1"}`,
},
{
shortcut: "+float-image-create",
sc: FloatImageCreate,
args: []string{"--sheet-id", "sh1", "--image-name", "logo.png", "--image-token", "tok", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
subInput: `{"sheet-id":"sh1","image-name":"logo.png","image-token":"tok","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
},
{
shortcut: "+float-image-delete",
sc: FloatImageDelete,
args: []string{"--sheet-id", "sh1", "--float-image-id", "fi1"},
subInput: `{"sheet-id":"sh1","float-image-id":"fi1"}`,
},
}
for _, tc := range cases {
t.Run(tc.shortcut, func(t *testing.T) {
t.Parallel()
mapping, ok := batchOpDispatch[tc.shortcut]
if !ok {
t.Fatalf("%s not in batchOpDispatch", tc.shortcut)
}
// Standalone body via the shortcut's own dry-run.
standaloneBody := decodeToolInput(t, parseDryRunBody(t, tc.sc, append([]string{"--url", testURL}, tc.args...)), mapping.mcpToolName)
// Batch body via the +batch-update translator.
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
fv := newMapFlagViewForCommand(tc.shortcut, subInput)
// Match what translateBatchOp does — read the sheet selector
// via the shortcut-specific flag names so +pivot-create
// (target-sheet-id / target-sheet-name) and the rest
// (sheet-id / sheet-name) both resolve correctly.
sidFlag, snameFlag := sheetSelectorFlagsForSubOp(tc.shortcut)
sidStr, _ := subInput[sidFlag].(string)
snameStr, _ := subInput[snameFlag].(string)
batchBody, err := mapping.translate(fv, testToken, sidStr, snameStr)
if err != nil {
t.Fatalf("batch translate failed: %v", err)
}
// Round-trip the batch body through JSON so number types match the
// standalone path (which is decoded from a JSON string).
batchBody = jsonRoundTrip(t, batchBody)
if !reflect.DeepEqual(standaloneBody, batchBody) {
t.Errorf("%s: batch body != standalone body\n standalone=%#v\n batch =%#v", tc.shortcut, standaloneBody, batchBody)
}
})
}
}
func jsonRoundTrip(t *testing.T, m map[string]interface{}) map[string]interface{} {
t.Helper()
b, err := json.Marshal(m)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
// TestBatchOp_ErrorEquivalence is the second half of the contract: for the
// same bad input, the standalone shortcut Validate and the +batch-update
// sub-op translator must emit the same friendly CLI error. Previously a
// sub-op that omitted --sheet-id (or another required flag) slipped through
// to the server and surfaced as "sheet undefined not found"; with the
// validation pushed down into the xxxInput builders both paths now stop the
// request before the API call.
//
// Scope: this test covers checks that cobra cannot enforce — XOR pairs
// (sheet selector, image token/uri), range relationships, enum-bound rules,
// pixel/size cross-flag coupling. cobra's own MarkFlagRequired catches the
// single-required cases on the standalone path with its own
// "required flag(s) \"X\" not set" wording; the batch path now catches the
// same situations with our friendlier "--X is required" wording — those are
// asserted by TestBatchOp_RejectsBadSubOpInput below.
func TestBatchOp_ErrorEquivalence(t *testing.T) {
t.Parallel()
cases := []struct {
name string
// shortcut & standalone args. --url is supplied by the runner. Args
// satisfy every cobra-required flag so cobra doesn't short-circuit
// before our shared validator runs.
shortcut common.Shortcut
args []string
// matching sub-op input; reach the same failing check.
subShortcut string
subInput string
// substring expected in both errors. We assert *contains* rather than
// equality because the batch path wraps the inner error with
// "operations[i] (<name>): " context — the inner message must match.
wantContains string
}{
{
name: "+cells-set missing sheet selector",
shortcut: CellsSet,
args: []string{"--range", "A1", "--cells", `[[{"value":"x"}]]`},
subShortcut: "+cells-set",
subInput: `{"range":"A1","cells":[[{"value":"x"}]]}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+cells-set both sheet-id and sheet-name",
shortcut: CellsSet,
args: []string{"--sheet-id", "sh1", "--sheet-name", "Sheet1", "--range", "A1", "--cells", `[[{"value":"x"}]]`},
subShortcut: "+cells-set",
subInput: `{"sheet-id":"sh1","sheet-name":"Sheet1","range":"A1","cells":[[{"value":"x"}]]}`,
wantContains: "mutually exclusive",
},
{
name: "+dim-insert missing sheet selector",
shortcut: DimInsert,
args: []string{"--position", "1", "--count", "1"},
subShortcut: "+dim-insert",
subInput: `{"position":"1","count":1}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+dim-insert count <= 0",
shortcut: DimInsert,
args: []string{"--sheet-id", "sh1", "--position", "5", "--count", "0"},
subShortcut: "+dim-insert",
subInput: `{"sheet-id":"sh1","position":"5","count":0}`,
wantContains: "--count must be > 0",
},
{
name: "+rows-resize --type pixel without --size",
shortcut: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
subShortcut: "+rows-resize",
subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
wantContains: "--type pixel requires --size",
},
{
name: "+sheet-delete missing sheet selector",
shortcut: SheetDelete,
args: []string{},
subShortcut: "+sheet-delete",
subInput: `{}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+float-image-create both image-token and image-uri",
shortcut: FloatImageCreate,
args: []string{"--sheet-id", "sh1", "--image-name", "x.png", "--image-token", "t", "--image-uri", "u", "--position-row", "0", "--position-col", "A", "--size-width", "100", "--size-height", "50"},
subShortcut: "+float-image-create",
subInput: `{"sheet-id":"sh1","image-name":"x.png","image-token":"t","image-uri":"u","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
wantContains: "mutually exclusive",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Standalone path: run the shortcut with --dry-run + bad args.
// Validate runs before DryRun, so we expect it to fail there.
_, _, standaloneErr := runShortcutCapturingErr(
t, tc.shortcut,
append([]string{"--url", testURL, "--dry-run"}, tc.args...),
)
if standaloneErr == nil {
t.Fatalf("standalone Validate accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(standaloneErr.Error(), tc.wantContains) {
t.Errorf("standalone error = %q, want substring %q", standaloneErr.Error(), tc.wantContains)
}
// Batch path: translate the matching sub-op. The translator wraps
// the inner error with "operations[i] (<shortcut>): " — assert the
// inner message survives the wrap.
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{
"shortcut": tc.subShortcut,
"input": subInput,
}
_, batchErr := translateBatchOp(rawOp, testToken, 0)
if batchErr == nil {
t.Fatalf("batch translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(batchErr.Error(), tc.wantContains) {
t.Errorf("batch error = %q, want substring %q (operations[i] prefix is fine)", batchErr.Error(), tc.wantContains)
}
// And the wrap context must include the sub-op index + shortcut
// name so error reports stay actionable in multi-op batches.
wrapHint := "operations[0] (" + tc.subShortcut + "):"
if !strings.Contains(batchErr.Error(), wrapHint) {
t.Errorf("batch error %q missing context prefix %q", batchErr.Error(), wrapHint)
}
})
}
}
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
// silent-coercion gap: `operations` skips parse-time schema validation, and
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
// whose JSON type contradicts its flag-defs type must be rejected up front
// rather than landing as 0 / false in the wrong place.
func TestBatchOp_RejectsWrongScalarType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
name: "int flag given a string",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":"abc"}`,
wantContains: "--index must be a number",
},
{
name: "int flag given a boolean",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":true,"index":0}`,
wantContains: "--source-index must be a number",
},
{
// Standalone cobra rejects 1.5 for an int flag at parse time;
// mapFlagView.Int would silently truncate it to 1, so the batch
// path must reject it too instead of executing on a floored index.
name: "int flag given a non-integer number",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":1.5}`,
wantContains: "--index must be an integer",
},
{
name: "bool flag given a string",
subShortcut: "+cells-set",
subInput: `{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]],"allow-overwrite":"true"}`,
wantContains: "--allow-overwrite must be a boolean",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted wrong-typed field; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_GuardsBeyondCobra locks the two batch sub-ops whose standalone
// required-flag enforcement lives OUTSIDE the shared *Input builder — so it is
// invisible to TestBatchOp_ErrorEquivalence and was missed by the refactor:
// - +csv-put: standalone requires one-of(start-cell, range) via cobra's
// MarkFlagsOneRequired (PostMount); a batch sub-op never runs cobra.
// - +sheet-move: standalone requires --index (>=0) and source-index>=0 in
// SheetMove.Validate; the batch path uses a dedicated builder.
//
// Without an explicit guard, mapFlagView's flag-default fallback silently wins
// (start-cell→"A1", index→0), so the batch sub-op diverges from the standalone
// contract instead of failing.
func TestBatchOp_GuardsBeyondCobra(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
name: "+csv-put without start-cell or range",
subShortcut: "+csv-put",
subInput: `{"sheet-id":"sh1","csv":"a,b"}`,
wantContains: "--start-cell or --range is required",
},
{
name: "+sheet-move without index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2}`,
wantContains: "requires index",
},
{
name: "+sheet-move negative index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":2,"index":-1}`,
wantContains: "--index must be >= 0",
},
{
name: "+sheet-move negative source-index",
subShortcut: "+sheet-move",
subInput: `{"sheet-id":"sh1","source-index":-1,"index":0}`,
wantContains: "--source-index must be >= 0",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.subShortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translateBatchOp accepted bad input; want error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_RejectsBadSubOpInput pins down the secondary guard: for
// inputs that cobra's MarkFlagRequired catches on the standalone path,
// the +batch-update sub-op (which has no cobra layer) must still reject
// CLI-side with its own friendly error before issuing any API call. This
// closes the original bug — a sub-op missing --sheet-id used to slip
// through and surface as "sheet undefined not found" only after a
// network round-trip.
func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
{
"+cells-set missing --range",
"+cells-set",
`{"sheet-id":"sh1","cells":[[{"value":"x"}]]}`,
"--range is required",
},
{
"+dim-insert missing --position",
"+dim-insert",
`{"sheet-id":"sh1","count":1}`,
"--position is required",
},
{
"+rows-resize missing --type",
"+rows-resize",
`{"sheet-id":"sh1","range":"1:1"}`,
"--type is required",
},
{
"+range-copy missing --target-range",
"+range-copy",
`{"sheet-id":"sh1","source-range":"A1:B2"}`,
"--target-range is required",
},
{
"+sheet-rename missing --title",
"+sheet-rename",
`{"sheet-id":"sh1"}`,
"--title is required",
},
{
"+chart-update missing --chart-id",
"+chart-update",
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
"--chart-id is required",
},
{
"+filter-create missing --range",
"+filter-create",
`{"sheet-id":"sh1"}`,
"--range is required",
},
{
"+float-image-update missing --float-image-id",
"+float-image-update",
`{"sheet-id":"sh1","image-name":"x.png","image-token":"t","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
"--float-image-id is required",
},
// +float-image-update's core (image_name / position / size) is mandatory
// on update too — the tool rejects without them and +float-image-list
// can't backfill image_name. cobra gates these on the standalone path;
// the batch sub-op must reject them here. The image source stays optional
// (omitting it keeps the current image), so these inputs omit it.
{
"+float-image-update missing --image-name",
"+float-image-update",
`{"sheet-id":"sh1","float-image-id":"fi1","position-row":0,"position-col":"A","size-width":100,"size-height":50}`,
"--image-name is required",
},
{
"+float-image-update missing position",
"+float-image-update",
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","size-width":100,"size-height":50}`,
"--position-row and --position-col are required",
},
{
"+float-image-update missing size",
"+float-image-update",
`{"sheet-id":"sh1","float-image-id":"fi1","image-name":"x.png","position-row":0,"position-col":"A"}`,
"--size-width and --size-height are required",
},
// +filter-{update,delete} need sheet-id (not sheet-name) because
// server contract: filter_id === sheet_id, and we can't resolve
// sheet-name → sheet-id mid-batch.
{
"+filter-update with --sheet-name only (filter_id must equal sheet_id)",
"+filter-update",
`{"sheet-name":"Sheet1","range":"A1:F1000","properties":{"rules":[]}}`,
"+filter-update requires --sheet-id",
},
{
"+filter-delete with --sheet-name only (filter_id must equal sheet_id)",
"+filter-delete",
`{"sheet-name":"Sheet1"}`,
"+filter-delete requires --sheet-id",
},
// +sparkline-update requires sparkline_id on every
// properties.sparklines[i] (server contract). CLI surfaces this
// with a pointer to +sparkline-list so the agent doesn't have to
// guess the id from an opaque server-side rejection.
{
"+sparkline-update item missing sparkline_id",
"+sparkline-update",
`{"sheet-id":"sh1","group-id":"g1","properties":{"sparklines":[{"position":{"row":0,"col":"A"}}]}}`,
"missing sparkline_id",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{
"shortcut": tc.subShortcut,
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted bad input — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_SchemaValidatesSubOps confirms the schema-driven
// validator fires on +batch-update sub-operations the same way it
// fires on standalone shortcuts. mapFlagView.Command() returns the
// sub-op's shortcut name, so validateInputAgainstSchema (called at
// each input builder's tail) routes through the same (command, flag)
// lookup pipeline a standalone invocation would. This regression
// pins that wiring — without it, agents could slip past CLI-side
// schema checks by wrapping a bad input in +batch-update.
func TestBatchOp_SchemaValidatesSubOps(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subShortcut string
subInput string
wantContains string
}{
// +pivot-create properties.values items enforce summarize_by
// enum — schema rejects an out-of-enum value as a sub-op too.
{
"+pivot-create summarize_by out of enum",
"+pivot-create",
`{"sheet-id":"sh1","source":"Sheet1!A1:D100","properties":{"values":[{"field":"A","summarize_by":"BOGUS"}]}}`,
"summarize_by",
},
// +chart-create properties.position.row has minimum:0 — P0
// addition; validator must catch -1 even in the batch path.
{
"+chart-create position.row below minimum",
"+chart-create",
`{"sheet-id":"sh1","properties":{"position":{"row":-1,"col":"A"},"size":{"width":400,"height":300}}}`,
"below minimum",
},
// +cells-set --cells is a 2D array of objects per the
// upstream-fixed schema; sub-op passing an object must be
// rejected at the schema layer (not "expected JSON array").
{
"+cells-set cells wrong shape",
"+cells-set",
`{"sheet-id":"sh1","range":"A1","cells":{"foo":"bar"}}`,
`expected type "array"`,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.subInput), &subInput); err != nil {
t.Fatalf("bad subInput JSON: %v", err)
}
rawOp := map[string]interface{}{
"shortcut": tc.subShortcut,
"input": subInput,
}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Fatalf("translator accepted schema-violating sub-op — expected error containing %q", tc.wantContains)
}
if !strings.Contains(err.Error(), tc.wantContains) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.wantContains)
}
})
}
}
// TestBatchOp_DispatchCoversReportedBugs is a focused guard for the two
// originally reported failures: +range-copy and +rows-resize sub-ops must
// translate to the correct MCP body (not a near-passthrough that drops
// required fields).
func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
t.Parallel()
// +range-copy → transform_range with range / destination_range (not the
// raw source_range / target_range that used to leak through).
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+range-copy","input":{"sheet-id":"sh1","source-range":"A1:B2","target-range":"A10","paste-type":"all"}}]`,
"--yes",
})
ops := decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
copyIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if copyIn["range"] != "A1:B2" || copyIn["destination_range"] != "A10" {
t.Errorf("+range-copy sub-op body wrong: %#v", copyIn)
}
if copyIn["operation"] != "copy" {
t.Errorf("+range-copy operation = %v, want copy", copyIn["operation"])
}
// +rows-resize → resize_range with range + resize_height. The CLI's single
// "23" input must be expanded to "23:23" because resize_range rejects
// bare single-element ranges.
body = parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
"--yes",
})
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})
resizeIn := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if resizeIn["range"] != "23:23" {
t.Errorf("+rows-resize single-row range = %v, want 23:23", resizeIn["range"])
}
rh, _ := resizeIn["resize_height"].(map[string]interface{})
if rh == nil || rh["type"] != "pixel" {
t.Errorf("+rows-resize resize_height wrong: %#v", resizeIn)
}
}
// TestBatchOp_RequiredFlagParity is the systematic standalone-vs-batch parity
// contract: for EVERY batchable shortcut, a +batch-update sub-op that satisfies
// the sheet locator but omits all of the shortcut's business-required flags must
// fail in translateBatchOp — never silently fall back to a default. The earlier
// cases (TestBatchOp_ErrorEquivalence / GuardsBeyondCobra) cover hand-picked
// shortcuts; this one is data-driven over batchOpDispatch + flag-defs, so it
// guards the whole surface and auto-covers any shortcut added later. If a future
// refactor moves a required check out of the shared *Input builder (the exact
// failure mode behind the csv-put / sheet-move gaps), the corresponding sub-op
// would start accepting missing args and this test fails.
func TestBatchOp_RequiredFlagParity(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatalf("loadFlagDefs: %v", err)
}
// Flags supplied by the +batch-update top level (url/token), or that form the
// sub-op's own sheet selector, are context — not "business" inputs.
locator := map[string]bool{
"url": true, "spreadsheet-token": true,
"sheet-id": true, "sheet-name": true,
"target-sheet-id": true, "target-sheet-name": true,
}
// How each command expresses its sheet locator in a sub-op, so the error we
// trigger is the business one, not a missing-locator error.
sheetSel := func(cmd string) map[string]interface{} {
switch cmd {
case "+sheet-create": // create needs no existing-sheet anchor
return map[string]interface{}{}
case "+pivot-create": // placement selector is target-sheet-*; data source is --source
return map[string]interface{}{"target-sheet-id": "sh1"}
default:
return map[string]interface{}{"sheet-id": "sh1"}
}
}
for cmd := range batchOpDispatch {
spec, ok := defs[cmd]
if !ok {
t.Errorf("%s is in batchOpDispatch but has no flag-defs entry", cmd)
continue
}
var business []string
for _, fl := range spec.Flags {
if fl.Kind == "system" || locator[fl.Name] {
continue
}
if fl.Required == "required" || fl.Required == "xor" {
business = append(business, fl.Name)
}
}
if len(business) == 0 {
continue // only-locator commands (sheet-delete/hide/unhide/copy/filter-delete): nothing to omit
}
t.Run(cmd, func(t *testing.T) {
t.Parallel()
rawOp := map[string]interface{}{"shortcut": cmd, "input": sheetSel(cmd)}
_, err := translateBatchOp(rawOp, testToken, 0)
if err == nil {
t.Errorf("%s: a sub-op omitting business-required %v was accepted; want an error "+
"(batch must reject missing required flags, not silently default)", cmd, business)
return
}
// The sub-op DID supply a sheet selector, so a missing-locator error
// would mean the fixture is wrong and the business-required check never
// actually ran — reject that shape so the parity check stays honest.
if strings.Contains(err.Error(), "specify at least one of") {
t.Errorf("%s: got a missing-locator error, not a business-required one (fixture bug): %v", cmd, err)
}
})
}
}

View File

@@ -1,342 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── +batch-update sub-op dispatch ─────────────────────────────────────
//
// 用户传给 +batch-update --operations 的形态是 CLI 视角的 {shortcut, input}
//
// [{"shortcut": "+range-copy", "input": {"sheet_id":"...","source-range":"A1:B2","target-range":"A10"}}, ...]
//
// input 里用的是该 shortcut 的 **CLI flag 名**(与 standalone 调用一致;连字符 /
// 下划线两种写法都接受)。底层 MCP batch_update tool 要的是
// {tool_name, input(MCP body)} —— body 的字段名往往与 CLI flag 名不同
// (如 +range-copy 的 source-range/target-range 要翻成 range/destination_range
//
// 关键:每个子操作复用 **standalone shortcut 同一套 flag→body translator**
// (那些 *Input 构建函数,现在统一接收 flagView 接口)。这样 batch 子操作
// 产出的 MCP body 与该 shortcut 单独调用产出的 body 完全一致(由
// batch-vs-standalone 契约测试保证。dispatch 表只列**可纳入 atomic batch
// 的 write shortcut**——读操作、fan-out wrapper+batch-update 自身、
// +cells-batch-set-style、+cells-batch-clear、+dropdown-{update,delete})一律不放进表里,
// 用户传到 +batch-update 里会被 translator 拒绝。
// batchTranslateFn turns a sub-op's CLI-shape input (via flagView) into the MCP
// tool body for the underlying batch_update sub-tool. token is the
// +batch-update top-level spreadsheet token; sheetID/sheetName are the resolved
// sheet selector for this sub-op. The returned body already carries excel_id
// and (where the tool needs one) the operation discriminator — exactly as the
// standalone shortcut would emit.
type batchTranslateFn func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error)
type batchOpMapping struct {
// mcpToolName 是底层 MCP batch_update 接受的 tool_name。
mcpToolName string
// translate 复用 standalone 的 *Input 构建逻辑,产出 MCP body。
translate batchTranslateFn
}
// sheetSelectorFlagsForSubOp returns the (id, name) flag names a +batch-update
// sub-op uses to express its placement / context sheet. Defaults are
// `sheet-id` / `sheet-name`; +pivot-create deviates because its create
// shortcut renamed the placement selector to `target-sheet-id` /
// `target-sheet-name` (the data-source sheet is encoded in --source as
// `'SheetName'!Range`, not in a sheet selector flag). Update / delete on
// pivot still use the default names — only the create create-side
// shortcut was renamed.
func sheetSelectorFlagsForSubOp(shortcut string) (string, string) {
if shortcut == "+pivot-create" {
return "target-sheet-id", "target-sheet-name"
}
return "sheet-id", "sheet-name"
}
// objCreateTranslate / objUpdateTranslate / objDeleteTranslate bind an object
// CRUD spec to the shared object_crud builders.
func objCreateTranslate(spec objectCRUDSpec) batchTranslateFn {
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
return objectCreateInput(fv, token, sheetID, sheetName, spec)
}
}
func objUpdateTranslate(spec objectCRUDSpec) batchTranslateFn {
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
return objectUpdateInput(fv, token, sheetID, sheetName, spec)
}
}
func objDeleteTranslate(spec objectCRUDSpec) batchTranslateFn {
return func(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
return objectDeleteInput(fv, token, sheetID, sheetName, spec)
}
}
// batchOpDispatch covers every write shortcut that can join an atomic batch.
// Each entry plugs the shortcut's standalone xxxInput builder into the
// batch translator path — so the body is byte-identical to the standalone
// invocation (locked by TestBatchOp_BodyMatchesStandalone) and the missing-
// flag error is identical too (locked by TestBatchOp_ErrorEquivalence).
var batchOpDispatch = map[string]batchOpMapping{
// ─── 单元格内容 ──────────────────────────────────────────────────
"+cells-set": {"set_cell_range", cellsSetInput},
"+cells-set-style": {"set_cell_range", cellsSetStyleInput},
"+cells-clear": {"clear_cell_range", cellsClearInput},
"+cells-replace": {"replace_data", replaceInput},
"+csv-put": {"set_range_from_csv", csvPutInput},
"+dropdown-set": {"set_cell_range", dropdownSetInput},
// ─── 单元格合并 (merge_cells, operation 区分) ────────────────────
"+cells-merge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return mergeInput(fv, token, sid, sname, "merge", true)
}},
"+cells-unmerge": {"merge_cells", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return mergeInput(fv, token, sid, sname, "unmerge", false)
}},
// ─── 行列结构 (modify_sheet_structure, operation 区分) ──────────
"+dim-insert": {"modify_sheet_structure", dimInsertInput},
"+dim-delete": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimRangeOpInput(fv, token, sid, sname, "delete")
}},
"+dim-hide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimRangeOpInput(fv, token, sid, sname, "hide")
}},
"+dim-unhide": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimRangeOpInput(fv, token, sid, sname, "unhide")
}},
"+dim-freeze": {"modify_sheet_structure", dimFreezeInput},
"+dim-group": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimGroupInput(fv, token, sid, sname, "group")
}},
"+dim-ungroup": {"modify_sheet_structure", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return dimGroupInput(fv, token, sid, sname, "ungroup")
}},
// ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return resizeInput(fv, token, sid, sname, "row")
}},
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return resizeInput(fv, token, sid, sname, "column")
}},
// ─── 区域操作 (transform_range, operation 区分) ─────────────────
"+range-move": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return transformMoveCopyInput(fv, token, sid, sname, "move", false)
}},
"+range-copy": {"transform_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
return transformMoveCopyInput(fv, token, sid, sname, "copy", true)
}},
"+range-fill": {"transform_range", rangeFillInput},
"+range-sort": {"transform_range", rangeSortInput},
// ─── 工作簿 / 子表 (modify_workbook_structure, operation 区分) ──
"+sheet-create": {"modify_workbook_structure", func(fv flagView, token, _, _ string) (map[string]interface{}, error) {
return sheetCreateInput(fv, token)
}},
"+sheet-delete": {"modify_workbook_structure", sheetDeleteInput},
"+sheet-rename": {"modify_workbook_structure", sheetRenameInput},
"+sheet-move": {"modify_workbook_structure", sheetMoveBatchInput},
"+sheet-copy": {"modify_workbook_structure", sheetCopyInput},
"+sheet-hide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "hide")
}},
"+sheet-unhide": {"modify_workbook_structure", func(fv flagView, t, sid, sn string) (map[string]interface{}, error) {
return sheetVisibilityInput(fv, t, sid, sn, "unhide")
}},
"+sheet-set-tab-color": {"modify_workbook_structure", sheetSetTabColorInput},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},
"+pivot-delete": {"manage_pivot_table_object", objDeleteTranslate(pivotSpec)},
"+cond-format-create": {"manage_conditional_format_object", objCreateTranslate(condFormatSpec)},
"+cond-format-update": {"manage_conditional_format_object", objUpdateTranslate(condFormatSpec)},
"+cond-format-delete": {"manage_conditional_format_object", objDeleteTranslate(condFormatSpec)},
"+filter-create": {"manage_filter_object", filterCreateInput},
"+filter-update": {"manage_filter_object", filterUpdateInput},
"+filter-delete": {"manage_filter_object", filterDeleteInput},
"+filter-view-create": {"manage_filter_view_object", objCreateTranslate(filterViewSpec)},
"+filter-view-update": {"manage_filter_view_object", objUpdateTranslate(filterViewSpec)},
"+filter-view-delete": {"manage_filter_view_object", objDeleteTranslate(filterViewSpec)},
"+sparkline-create": {"manage_sparkline_object", objCreateTranslate(sparklineSpec)},
"+sparkline-update": {"manage_sparkline_object", objUpdateTranslate(sparklineSpec)},
"+sparkline-delete": {"manage_sparkline_object", objDeleteTranslate(sparklineSpec)},
"+float-image-create": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectLocalImageInBatch(fv); err != nil {
return nil, err
}
return floatImageWriteInput(fv, token, sid, sname, "create", false, "")
}},
"+float-image-update": {"manage_float_image_object", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectLocalImageInBatch(fv); err != nil {
return nil, err
}
return floatImageWriteInput(fv, token, sid, sname, "update", true, "")
}},
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
}
// rejectLocalImageInBatch blocks the local-file --image source inside
// +batch-update: a batch sub-op has no upload phase, so the file could not be
// turned into a file_token. Callers must pass --image-token / --image-uri.
func rejectLocalImageInBatch(fv flagView) error {
if strings.TrimSpace(fv.Str("image")) != "" {
return common.FlagErrorf("--image (local upload) is not supported inside +batch-update; pass --image-token or --image-uri instead")
}
return nil
}
// sheetMoveBatchInput translates +sheet-move inside a batch. Unlike the
// standalone shortcut it cannot issue the get_workbook_structure read that
// auto-derives sheet_id / source_index, so both must be supplied explicitly.
func sheetMoveBatchInput(fv flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if sheetID == "" {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires sheet_id (sheet_name needs a network lookup unavailable mid-batch)")
}
if !fv.Changed("source-index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires source_index (auto-derive needs a network lookup unavailable mid-batch)")
}
if fv.Int("source-index") < 0 {
return nil, common.FlagErrorf("--source-index must be >= 0")
}
// Standalone +sheet-move requires --index (see SheetMove.Validate). A batch
// sub-op skips that path, and mapFlagView falls back to the flag default (0),
// which would silently move the sheet to the front. Require it explicitly so
// the batch contract matches the standalone one.
if !fv.Changed("index") {
return nil, common.FlagErrorf("+sheet-move in +batch-update requires index")
}
if fv.Int("index") < 0 {
return nil, common.FlagErrorf("--index must be >= 0")
}
return map[string]interface{}{
"excel_id": token,
"operation": "move",
"sheet_id": sheetID,
"source_index": fv.Int("source-index"),
"target_index": fv.Int("index"),
}, nil
}
// reservedSubOpKeys 是禁止用户在 sub-op input 里手填的 key —— 它们由
// +batch-update 顶层 --url/--token 统一提供excel_id / spreadsheet_token / url
var reservedSubOpKeys = []string{"excel_id", "spreadsheet_token", "url"}
// translateBatchOp 把一个 CLI 视角的 {shortcut, input} 翻成底层 MCP
// batch_update 的 {tool_name, input}。`index` 用于错误信息定位。input 用
// shortcut 的 CLI flag 名(连字符/下划线均可),经该 shortcut 的 standalone
// translator 翻成 MCP body。
//
// 失败场景:
// - shortcut 字段缺失 / 非 string
// - shortcut 不在 dispatch 表拼写错read 操作;嵌套 fan-out wrapper
// - input 不是 object
// - input 里手填了 operation由 shortcut 名隐含,禁手填以防 mismatch
// - input 里手填了 excel_id / spreadsheet_token / url
// - 子操作的 translator 报错(如缺必填字段)
func translateBatchOp(raw interface{}, token string, index int) (map[string]interface{}, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] must be a JSON object", index)
}
scRaw, present := op["shortcut"]
if !present {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' field is required", index)
}
sc, ok := scRaw.(string)
if !ok || sc == "" {
return nil, common.FlagErrorf("operations[%d]: 'shortcut' must be a non-empty string (got %T)", index, scRaw)
}
mapping, ok := batchOpDispatch[sc]
if !ok {
return nil, common.FlagErrorf(
"operations[%d]: shortcut %q not allowed in +batch-update "+
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
index, sc,
)
}
inputRaw, hasInput := op["input"]
var input map[string]interface{}
if !hasInput || inputRaw == nil {
input = map[string]interface{}{}
} else {
input, ok = inputRaw.(map[string]interface{})
if !ok {
return nil, common.FlagErrorf("operations[%d] (%s): 'input' must be a JSON object (got %T)", index, sc, inputRaw)
}
}
// 禁手填 operation —— 由 shortcut 名表达,手填易与 shortcut 不一致。
if _, has := input["operation"]; has {
return nil, common.FlagErrorf(
"operations[%d] (%s): do not pass input.operation manually — it is implied by the shortcut name",
index, sc,
)
}
// 禁在 sub-op 重复填 spreadsheet 定位 —— 由 +batch-update 顶层 --url/--token 统一提供。
for _, k := range reservedSubOpKeys {
if _, has := input[k]; has {
return nil, common.FlagErrorf(
"operations[%d] (%s): do not pass input.%s — it is already set from +batch-update top-level --url / --token",
index, sc, k,
)
}
}
// 拒绝任何额外的 sub-op 顶层 key防御未来 schema drift / 用户笔误)。
for k := range op {
if k != "shortcut" && k != "input" {
return nil, common.FlagErrorf("operations[%d] (%s): unknown top-level key %q (expected only 'shortcut' and 'input')", index, sc, k)
}
}
fv := newMapFlagViewForCommand(sc, input)
// operations is skipped by parse-time schema validation, so type-check the
// sub-op's scalar fields here before the translator reads them via
// Int/Bool/Float64 (which would otherwise coerce a wrong type to zero).
if err := fv.validateRawTypes(); err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
}
sheetIDFlag, sheetNameFlag := sheetSelectorFlagsForSubOp(sc)
sheetID := strings.TrimSpace(fv.Str(sheetIDFlag))
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
body, err := mapping.translate(fv, token, sheetID, sheetName)
if err != nil {
return nil, common.FlagErrorf("operations[%d] (%s): %v", index, sc, err)
}
return map[string]interface{}{
"tool_name": mapping.mcpToolName,
"input": body,
}, nil
}
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 {
return nil, common.FlagErrorf("--operations must be a non-empty JSON array")
}
out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i)
if err != nil {
return nil, err
}
out = append(out, translated)
}
return out, nil
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// +csv-put locates with --start-cell, while +csv-get / +cells-set locate with
// --range. Agents routinely carry --range over to +csv-put and hit a guaranteed
// first-try failure. csvPutInput now accepts --range as an alias for
// --start-cell; a range value collapses to its top-left cell.
func TestCsvPutInput_RangeAliasForStartCell(t *testing.T) {
tests := []struct {
name string
raw map[string]interface{}
wantAnchor string
}{
{"start-cell direct (unchanged)", map[string]interface{}{"csv": "a,b", "start-cell": "B2"}, "B2"},
{"range alias, single cell", map[string]interface{}{"csv": "a,b", "range": "B2"}, "B2"},
{"range alias collapses to top-left", map[string]interface{}{"csv": "a,b", "range": "A1:H17"}, "A1"},
{"start-cell wins when both set", map[string]interface{}{"csv": "a,b", "start-cell": "C3", "range": "A1:H17"}, "C3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", tt.raw)
input, err := csvPutInput(fv, "tok", "sid", "")
if err != nil {
t.Fatalf("csvPutInput returned error: %v", err)
}
got, _ := input["start_cell"].(string)
if got != tt.wantAnchor {
t.Errorf("start_cell = %q, want %q", got, tt.wantAnchor)
}
})
}
}
// With neither --start-cell nor --range explicitly set, csvPutInput rejects the
// call instead of silently anchoring at the "A1" flag default. Standalone never
// reaches this path — cobra's MarkFlagsOneRequired(start-cell, range) catches it
// first — but a +batch-update sub-op skips cobra, so the guard must live in the
// shared builder too. Otherwise a batch +csv-put with no anchor silently pastes
// at A1, diverging from the standalone contract.
func TestCsvPutInput_RequiresStartCellOrRange(t *testing.T) {
fv := newMapFlagViewForCommand("+csv-put", map[string]interface{}{"csv": "a,b"})
_, err := csvPutInput(fv, "tok", "sid", "")
if err == nil {
t.Fatal("csvPutInput accepted missing start-cell/range; want a required-flag error")
}
if !strings.Contains(err.Error(), "--start-cell or --range is required") {
t.Errorf("error = %q, want it to mention '--start-cell or --range is required'", err.Error())
}
}
// csvPutWriteRangeFromInput surfaces the real paste footprint so agents can see
// how far a CSV reaches from its anchor — it auto-expands to the CSV's own size,
// not to any user-set range.
func TestCsvPutWriteRangeFromInput(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
want string
ok bool
}{
{"3x3 at B2", map[string]interface{}{"start_cell": "B2", "csv": "a,b,c\n1,2,3\n4,5,6"}, "B2:D4", true},
{"single cell at A1", map[string]interface{}{"start_cell": "A1", "csv": "x"}, "A1:A1", true},
{"1 row 3 cols at C3", map[string]interface{}{"start_cell": "C3", "csv": "a,b,c"}, "C3:E3", true},
{"ragged rows use max width", map[string]interface{}{"start_cell": "A1", "csv": "a,b\nc,d,e"}, "A1:C2", true},
{"missing csv", map[string]interface{}{"start_cell": "A1"}, "", false},
{"non-single anchor", map[string]interface{}{"start_cell": "A1:B2", "csv": "x"}, "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := csvPutWriteRangeFromInput(tt.input)
if ok != tt.ok || got != tt.want {
t.Errorf("got (%q, %v), want (%q, %v)", got, ok, tt.want, tt.ok)
}
})
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,578 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
// verifies the shortcut decodes the JSON-string output, surfaces it as
// envelope data, and finishes without error.
func TestExecute_WorkbookInfo_Happy(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`)
out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub)
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("sheets len = %d, want 1", len(sheets))
}
sheet, _ := sheets[0].(map[string]interface{})
if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" {
t.Errorf("unexpected sheet: %#v", sheet)
}
}
// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the
// envelope shape and asserts CLI returns an error envelope.
func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
t.Parallel()
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
Body: map[string]interface{}{
"code": 1310201,
"msg": "spreadsheet not found",
"data": map[string]interface{}{},
},
}
stdout, stderr, err := func() (string, string, error) {
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
reg.Register(stub)
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
err := parent.Execute()
return stdout.String(), stderr.String(), err
}()
if err == nil {
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
}
combined := stdout + stderr + err.Error()
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
}
}
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
// when only --sheet-name is given (and --source-index omitted) first
// reads the workbook structure to derive sheet_id + source_index, then
// posts the modify_workbook_structure call.
func TestExecute_SheetMove_LookupsIndex(t *testing.T) {
t.Parallel()
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","index":3}]}`)
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
out, err := runShortcutWithStubs(t, SheetMove,
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
lookup, move,
)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
// Inspect the captured move body: source_index should be 3 (looked up),
// not <resolve>, and sheet_id should be the resolved id.
if move.CapturedBody == nil {
t.Fatal("move stub didn't capture a body")
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["sheet_id"] != "sh1" {
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name)", input["sheet_id"])
}
if input["source_index"].(float64) != 3 {
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
}
if input["target_index"].(float64) != 0 {
t.Errorf("target_index = %v, want 0", input["target_index"])
}
}
// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as
// above but with get_workbook_structure exposing the display name as "title"
// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex
// must resolve --sheet-name against either key.
func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) {
t.Parallel()
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`)
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
out, err := runShortcutWithStubs(t, SheetMove,
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
lookup, move,
)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if move.CapturedBody == nil {
t.Fatal("move stub didn't capture a body")
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["sheet_id"] != "sh1" {
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"])
}
if input["source_index"].(float64) != 3 {
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
}
}
// TestExecute_CellsGet covers a multi-range read end-to-end.
func TestExecute_CellsGet(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`)
out, err := runShortcutWithStubs(t, CellsGet,
[]string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
if data := decodeEnvelopeData(t, out); data["ranges"] == nil {
t.Fatalf("expected ranges in output; got=%#v", data)
}
}
// TestExecute_CellsSet covers the write path including allow-overwrite
// override.
func TestExecute_CellsSet(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`)
out, err := runShortcutWithStubs(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:B1",
"--cells", `[[{"value":"x"},{"value":"y"}]]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "set_cell_range")
if input["range"] != "A1:B1" {
t.Errorf("wire range = %v", input["range"])
}
if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 {
t.Errorf("updated_cells = %v", data["updated_cells"])
}
}
// TestExecute_DropdownSet covers the fan-out → set_cell_range write.
func TestExecute_DropdownSet(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{}`)
_, err := runShortcutWithStubs(t, DropdownSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A2:A4",
"--options", `["x","y"]`,
"--multiple",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "set_cell_range")
cells, _ := input["cells"].([]interface{})
if len(cells) != 3 {
t.Errorf("wire cells rows = %d, want 3", len(cells))
}
}
// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for
// dropdown-update. Verifies the captured request has 2 ops.
func TestExecute_DropdownUpdate_Batch(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`)
_, err := runShortcutWithStubs(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
"--options", `["a","b"]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 2 {
t.Errorf("operations len = %d, want 2", len(ops))
}
}
// TestExecute_CellsSearch covers the search read path with options.
func TestExecute_CellsSearch(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`)
out, err := runShortcutWithStubs(t, CellsSearch, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--find", "foo", "--match-case",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
data := decodeEnvelopeData(t, out)
if data["matches"] == nil {
t.Errorf("matches missing: %#v", data)
}
}
// TestExecute_RangeMove covers the transform_range write path.
func TestExecute_RangeMove(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"moved":true}`)
out, err := runShortcutWithStubs(t, RangeMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "A1:C5",
"--target-range", "D1",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "transform_range")
if input["operation"] != "move" {
t.Errorf("operation = %v, want move", input["operation"])
}
}
// TestExecute_FilterCreate covers the filter special case (range mandatory,
// optional --data conditions merge).
func TestExecute_FilterCreate(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`)
out, err := runShortcutWithStubs(t, FilterCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:F100",
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "manage_filter_object")
props, _ := input["properties"].(map[string]interface{})
if props["range"] != "A1:F100" {
t.Errorf("properties.range = %v", props["range"])
}
if props["rules"] == nil {
t.Errorf("rules missing: %#v", props)
}
}
// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape
// translation: user passes {shortcut, input}, batchOpDispatch maps it to
// {tool_name, input(+operation, +excel_id)} before the tool call. Also
// verifies --continue-on-error.
func TestExecute_BatchUpdate_Translated(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`,
"--continue-on-error",
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "batch_update")
if input["continue_on_error"] != true {
t.Errorf("continue_on_error not propagated: %#v", input)
}
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
op := ops[0].(map[string]interface{})
if op["tool_name"] != "set_cell_range" {
t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"])
}
subInput, _ := op["input"].(map[string]interface{})
if subInput["excel_id"] != testToken {
t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken)
}
if _, has := subInput["operation"]; has {
t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput)
}
}
// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope
// precedence: an explicit --continue-on-error=false must keep the strict
// transaction even when the --operations envelope carries continue_on_error:true,
// while an envelope value still applies when the flag is absent. Guards against
// the regression where the flag was read by value (runtime.Bool) rather than by
// Changed().
func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) {
t.Parallel()
envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}`
t.Run("explicit false overrides envelope", func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", envelope,
"--continue-on-error=false",
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
if input["continue_on_error"] == true {
t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"])
}
})
t.Run("envelope applies when flag absent", func(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
"--url", testURL,
"--operations", envelope,
"--yes",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
if input["continue_on_error"] != true {
t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"])
}
})
}
// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup +
// set_cell_range follow-up. Stubs all three endpoints.
func TestExecute_WorkbookCreate(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": "shtcnBRAND",
"title": "Sales",
},
},
},
}
// Initial fill first reads the workbook structure to resolve the default
// sheet's id (the create response doesn't echo it), then writes.
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
"--title", "Sales",
"--headers", `["Name","Score"]`,
"--values", `[["alice",95]]`,
}, create, structure, fill)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
ss, _ := data["spreadsheet"].(map[string]interface{})
if ss["spreadsheet_token"] != "shtcnBRAND" {
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
}
if data["initial_fill"] == nil {
t.Errorf("initial_fill missing in envelope")
}
// The fill must target the resolved first sheet, not an empty selector.
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
if fillInput["sheet_id"] != "shtFirst" {
t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"])
}
}
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
// the initial fill (no structure/fill calls fire) and finish with the
// spreadsheet created but no initial_fill — never panic on a nil fill map.
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, flag, val string }{
{"empty values", "--values", "[]"},
{"empty headers", "--headers", "[]"},
} {
t.Run(tc.name, func(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": "shtNEW", "title": "X"},
},
},
}
// Only the create stub is provided: an empty array must skip the fill
// entirely, so no structure/fill call fires (and no nil-map panic).
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
data := decodeEnvelopeData(t, out)
if data["initial_fill"] != nil {
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
}
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
}
})
}
}
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
// contract: when the spreadsheet is created but the follow-up fill can't resolve
// its first sheet, the error must be structured and retain spreadsheet_token so
// the caller can recover instead of orphaning the new workbook.
func TestExecute_WorkbookCreate_FillFailureKeepsToken(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": "shtNEW", "title": "X"},
},
},
}
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
// spreadsheet already exists — exercising the partial-success path.
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
if err == nil {
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
}
exitErr, ok := err.(*output.ExitError)
if !ok {
t.Fatalf("error type = %T, want *output.ExitError (structured)", err)
}
if exitErr.Detail == nil {
t.Fatal("ExitError.Detail is nil; want structured detail carrying the token")
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["spreadsheet_token"] != "shtNEW" {
t.Errorf("detail.spreadsheet_token = %v, want shtNEW (must survive the fill failure)", detail["spreadsheet_token"])
}
}
// TestExecute_DimMove covers the native v3 move_dimension call. CLI's
// --source-range "1:3" (1-based inclusive) is parsed into v3's
// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is
// parsed into destination_index=10.
func TestExecute_DimMove(t *testing.T) {
t.Parallel()
move := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"moved": true},
},
}
_, err := runShortcutWithStubs(t, DimMove, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--source-range", "1:3", "--target", "11",
}, move)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, move.CapturedBody)
src, _ := body["source"].(map[string]interface{})
if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 {
t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"])
}
if body["destination_index"].(float64) != 10 {
t.Errorf("destination_index = %v, want 10", body["destination_index"])
}
}
// TestExecute_ChartCreate covers the object-CRUD factory's create path.
func TestExecute_ChartCreate(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`)
out, err := runShortcutWithStubs(t, ChartCreate, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
data := decodeEnvelopeData(t, out)
if data["chart_id"] != "chartNEW" {
t.Errorf("chart_id = %v", data["chart_id"])
}
}
// TestExecute_SheetCreate hits the workbook write path with all four
// optional flags so the input builder + callTool wiring is exercised.
func TestExecute_SheetCreate(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`)
out, err := runShortcutWithStubs(t, SheetCreate, []string{
"--url", testURL,
"--title", "Q4",
"--index", "2",
"--row-count", "300",
"--col-count", "12",
}, stub)
if err != nil {
t.Fatalf("execute failed: %v\nout=%s", err, out)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "modify_workbook_structure")
if input["operation"] != "create" || input["sheet_name"] != "Q4" {
t.Errorf("input shape wrong: %#v", input)
}
if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 {
t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"])
}
}
// TestExecute_RangeSort exercises the sort_conditions JSON parsing
// alongside the boolean has_header.
func TestExecute_RangeSort(t *testing.T) {
t.Parallel()
stub := toolOutputStub(testToken, "write", `{"sorted":true}`)
_, err := runShortcutWithStubs(t, RangeSort, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1:D50",
"--has-header",
"--sort-keys", `[{"column":"B","ascending":true}]`,
}, stub)
if err != nil {
t.Fatalf("execute failed: %v", err)
}
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
input := decodeToolInput(t, body, "transform_range")
if input["operation"] != "sort" || input["has_header"] != true {
t.Errorf("input wrong: %#v", input)
}
conds, _ := input["sort_conditions"].([]interface{})
if len(conds) != 1 {
t.Errorf("sort_conditions len = %d", len(conds))
}
}
// decodeRawEnvelopeBody parses the raw JSON request body captured by an
// httpmock stub. Used by execute tests to inspect what the CLI sent on
// the wire (vs. dry-run tests that render the body up-front).
func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw))
}
return body
}

View File

@@ -1,82 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── flag definitions, sourced from sheet-skill-spec ───────────────────
//
// data/flag-defs.json is the canonical, full definition of every CLI flag
// (name, type, default, desc, enum, input, hidden, required, kind),
// generated by sheet-skill-spec's sync script. The sync script also emits
// flag_defs_gen.go — the compiled `flagDefs` map — so command startup pays
// no JSON unmarshal (the parse cost used to land on every CLI invocation,
// sheets or not). We build each shortcut's []common.Flag from flagDefs at
// assembly time, so flag metadata never has to be hand-written in Go.
//
// Flags with kind == "system" (--dry-run, --yes, ...) are NOT materialized
// here: the framework auto-injects them based on Risk / DryRun / HasFormat.
// Do not hand-edit flag_defs_gen.go or data/flag-defs.json; regenerate via
// the sync script. flag_defs_gen_test.go guards the two against drift.
type flagDef struct {
Name string `json:"name"`
Kind string `json:"kind"` // "public" | "own" | "system"
Type string `json:"type"` // string | bool | int | int64 | float64 | string_array | string_slice
Required string `json:"required"` // "required" | "optional" | "xor"
Desc string `json:"desc"`
Default string `json:"default"`
Hidden bool `json:"hidden"`
Enum []string `json:"enum"`
Input []string `json:"input"`
}
type commandDef struct {
Risk string `json:"risk"`
Flags []flagDef `json:"flags"`
}
// loadFlagDefs returns the compiled flag definitions (flag_defs_gen.go).
// The error return is always nil; it is retained so existing call sites that
// handled a parse error keep compiling. There is no longer a runtime parse.
func loadFlagDefs() (map[string]commandDef, error) {
return flagDefs, nil
}
// flagsFor builds the []common.Flag for a shortcut command directly from
// flag-defs.json. System-kind flags are skipped (the framework injects
// them). Panics if the command is absent or the JSON is malformed — this
// is a build-time data contract, so a missing entry is a programming error
// surfaced loudly at startup rather than a silent empty flag set.
func flagsFor(command string) []common.Flag {
defs, err := loadFlagDefs()
if err != nil {
panic(fmt.Sprintf("sheets: %v", err))
}
spec, ok := defs[command]
if !ok {
panic(fmt.Sprintf("sheets: no flag-defs.json entry for %q", command))
}
out := make([]common.Flag, 0, len(spec.Flags))
for _, df := range spec.Flags {
if df.Kind == "system" {
continue
}
out = append(out, common.Flag{
Name: df.Name,
Type: df.Type,
Default: df.Default,
Desc: df.Desc,
Hidden: df.Hidden,
Required: df.Required == "required",
Enum: df.Enum,
Input: df.Input,
})
}
return out
}

View File

@@ -1,927 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Code generated from data/flag-defs.json; DO NOT EDIT.
package sheets
// flagDefs is the compiled form of data/flag-defs.json — every CLI flag's
// metadata for every shortcut, emitted as a Go literal so command startup
// pays no JSON unmarshal (see flag_defs.go). Do not hand-edit; regenerate
// with `go generate ./shortcuts/sheets/...` after data/flag-defs.json
// changes.
var flagDefs = map[string]commandDef{
"+batch-update": {
Risk: "high-risk-write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator (independent from per-operation sheet locator)"},
{Name: "operations", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: [{\"shortcut\":\"+xxx-yyy\",\"input\":{...}}, ...]. shortcut uses CLI names; input is that shortcut's flag set — it includes the per-operation sheet locator (sheet_id or sheet_name) but not the spreadsheet token/url (pass that once at the top level via --url/--spreadsheet-token; +batch-update has no top-level --sheet-id). input keys are the shortcut's flags flattened into JSON (e.g. \"range\":\"A11:B12\"), not another nested layer. For basic flags use lark-cli sheets <shortcut> --help; for composite JSON flags use --print-schema --flag-name <flag>. Do not pass an explicit operation field. Strict transaction by default, pass --continue-on-error for soft batch; no nesting; executed serially.", Input: []string{"file", "stdin"}},
{Name: "continue-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "Continue with remaining operations when a sub-operation fails; default false (abort on first failure)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template for each sub-operation; no network side effects"},
},
},
"+cells-batch-clear": {
Risk: "high-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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-batch-set-style": {
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON (same shape as in +cells-set-style)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-clear": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to clear (A1 notation)"},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 200000", Default: "200000", Hidden: true},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-merge": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
{Name: "merge-type", Kind: "own", Type: "string", Required: "optional", Desc: "Merge direction (`+cells-merge` only)", Default: "all", Enum: []string{"all", "rows", "columns"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-replace": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find for replacement"},
{Name: "replacement", Kind: "own", Type: "string", Required: "required", Desc: "Replacement text; pass empty string `\"\"` to delete matched content"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Replace range (A1 notation); whole sheet when omitted"},
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also replace within formula text"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Required preflight: outputs `would_replace_count` for user confirmation before the actual replace"},
},
},
"+cells-search": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "find", Kind: "own", Type: "string", Required: "required", Desc: "Text to find (interpreted as regex when `--regex` is set)"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Search range (A1 notation); whole sheet when omitted"},
{Name: "match-case", Kind: "own", Type: "bool", Required: "optional", Desc: "Case-sensitive match"},
{Name: "match-entire-cell", Kind: "own", Type: "bool", Required: "optional", Desc: "Match the entire cell content"},
{Name: "regex", Kind: "own", Type: "bool", Required: "optional", Desc: "Interpret `--find` as a regex pattern"},
{Name: "include-formulas", Kind: "own", Type: "bool", Required: "optional", Desc: "Also search within formula text"},
{Name: "max-matches", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 5000", Default: "5000", Hidden: true},
{Name: "offset", Kind: "own", Type: "int", Required: "optional", Desc: "Skip the first N matches (for pagination); default 0", Default: "0"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-set": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Write range (A1 notation)"},
{Name: "cells", Kind: "own", Type: "string", Required: "required", Desc: "JSON 2D array `[[{cell},...],...]`, dimensions must match `--range`; each cell may carry `value` / `formula` / `cell_styles` / `note` / `rich_text` (incl. `type=\"embed-image\"` in-cell image); run `--print-schema` for full fields", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting non-empty cells (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "max-cells", Kind: "own", Type: "int", Required: "optional", Desc: "Safety cap; default 50000", Default: "50000", Hidden: true},
{Name: "copy-to-range", Kind: "own", Type: "string", Required: "optional", Desc: "Copy-to range (A1 notation): replicate what --cells wrote into --range (values/formulas/styles, per the fields actually passed) to this range; formula refs auto-shift (C2=B2 -> C3=B3). Write a one-row/one-block template then fill a whole column/area. Supports full rows '3:6', full columns 'C:E', to-col-end 'D3:D', to-row-end 'D3:3', and comma-separated multiple targets like 'C1:D2,E5:F6'."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-set-image": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target cell (A1 notation; must be a single cell, e.g. `A1`; start and end must be identical)"},
{Name: "image", Kind: "own", Type: "string", Required: "required", Desc: "Local image path (PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC)"},
{Name: "name", Kind: "own", Type: "string", Required: "optional", Desc: "Image file name (with extension); defaults to the basename of `--image`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-set-style": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
{Name: "font-line", Kind: "own", Type: "string", Required: "optional", Desc: "Font line style", Enum: []string{"none", "underline", "line-through"}},
{Name: "horizontal-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Horizontal alignment", Enum: []string{"left", "center", "right"}},
{Name: "vertical-alignment", Kind: "own", Type: "string", Required: "optional", Desc: "Vertical alignment", Enum: []string{"top", "middle", "bottom"}},
{Name: "word-wrap", Kind: "own", Type: "string", Required: "optional", Desc: "Word-wrap strategy", Enum: []string{"overflow", "auto-wrap", "word-clip"}},
{Name: "number-format", Kind: "own", Type: "string", Required: "optional", Desc: "Number format pattern (e.g. text `@`, number `0.00`, currency `$#,##0.00`, date `mm/dd/yyyy`)"},
{Name: "border-styles", Kind: "own", Type: "string", Required: "optional", Desc: "Border config JSON: `{ top: {style,color,weight}, bottom: ..., left: ..., right: ... }`; same shape for all 4 sides", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cells-unmerge": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range to merge / unmerge (A1 notation)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+chart-create": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+chart-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter to a single chart reference_id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+chart-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cols-resize": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-create": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON: `style` (required, applied on match), `attrs?` (rule-type-dependent params), `has_ref?`. `rule_type` and `ranges` are separate flags", Input: []string{"file", "stdin"}},
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by rule id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+cond-format-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "rule-id", Kind: "own", Type: "string", Required: "required", Desc: "Target rule id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Rule config JSON, same shape as `+cond-format-create --properties`; update overwrites the entire rule", Input: []string{"file", "stdin"}},
{Name: "rule-type", Kind: "own", Type: "string", Required: "required", Desc: "Conditional format rule type; takes precedence over the same-named field inside `--properties`", Enum: []string{"duplicateValues", "uniqueValues", "cellIs", "containsText", "timePeriod", "containsBlanks", "notContainsBlanks", "dataBar", "colorScale", "rank", "aboveAverage", "expression", "iconSet"}},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "A1 ranges where the conditional format applies, as a JSON array (e.g. `[\"A1:A100\",\"C2:C50\"]`); takes precedence over the same-named field inside `--properties`", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+csv-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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{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"},
},
},
"+csv-put": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "start-cell", Kind: "own", Type: "string", Required: "required", Desc: "Top-left A1 anchor (e.g. `A1`, `B5`; no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet); must be a single cell, range notation not accepted; the bottom-right is inferred from CSV row/column counts", Default: "A1"},
{Name: "csv", Kind: "own", Type: "string", Required: "required", Desc: "RFC 4180 CSV text; plain values only (no formulas / styles / comments)", Input: []string{"file", "stdin"}},
{Name: "allow-overwrite", Kind: "own", Type: "bool", Required: "optional", Desc: "Allow overwriting (default true); set false to error if any target cell is non-empty", Default: "true"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "alias for --start-cell (parity with +csv-get / +cells-set, which locate with --range); a range like A1:H17 collapses to its top-left cell", Hidden: true},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to delete; rows use 1-based numbers like `3:7` or `5` (single row), columns use letters like `C:F` or `C`"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); row/column deletion is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-freeze": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dimension", Kind: "own", Type: "string", Required: "required", Desc: "Dimension (row or column)", Enum: []string{"row", "column"}},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Freeze the first N rows/columns; pass 0 to unfreeze"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-group": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Nesting level for grouping; default 1", Default: "1"},
{Name: "group-state", Kind: "own", Type: "string", Required: "optional", Desc: "Initial group expand state", Default: "expand", Enum: []string{"expand", "fold"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to group; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-hide": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to hide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-insert": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "inherit-style", Kind: "own", Type: "string", Required: "optional", Desc: "Style inheritance for the new row/column: `before` (from preceding) / `after` (from following) / `none` (default)", Default: "none", Enum: []string{"before", "after", "none"}},
{Name: "position", Kind: "own", Type: "string", Required: "required", Desc: "Insert position (1-based row number like `3` or column letter like `C`); new rows/columns are inserted *before* this position"},
{Name: "count", Kind: "own", Type: "int", Required: "required", Desc: "Number of rows/columns to insert (must be > 0)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-move": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source row/column closed range to move; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "target", Kind: "own", Type: "string", Required: "required", Desc: "Destination position (the moved rows/columns are placed *before* this position); rows use 1-based row number like `12`, columns use column letter like `H`. Must match the dimension of --source-range"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-ungroup": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dim-unhide": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to unhide; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-delete": {
Risk: "high-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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range in A1 notation, e.g. `A2:A100` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-set": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A2:A100`)"},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select; default `false`"},
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+dropdown-update": {
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: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
{Name: "highlight", Kind: "own", Type: "bool", Required: "optional", Desc: "Pill-highlight switch. **Omitted = ON** (options cycle through a 10-color palette). Pass `--highlight=false` for a plain dropdown. Override colors via `--colors`."},
{Name: "source-range", Kind: "own", Type: "string", Required: "xor", Desc: "Source range for listFromRange dropdown (A1 + sheet prefix, e.g. `'Sheet1'!T1:T3`); maps to server `data_validation.range` and auto-sets `data_validation.type='listFromRange'`. XOR with `--options`: pass `--options` for an inline list (type=list), pass this for a range reference (type=listFromRange). `--colors` length rule unchanged (≤ source range cell count); `--highlight` / `--multiple` behave the same. When `--highlight` is on and the source covers more than 2000 cells, the server flags the dropdown as option-error (highlight + large source is an unsupported combo); CLI emits a stderr warning. Pass `--highlight=false` to suppress."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-create": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Filter range (A1 notation, including header row, e.g. `A1:F1000`); do not duplicate the range field inside `--properties`"},
{Name: "properties", Kind: "own", Type: "string", Required: "optional", Desc: "Filter rule JSON: `rules` (per-column rule array), `filtered_columns?` (active column index hint). The flag is optional overall — if provided, `rules` must be non-empty; if omitted, an empty filter is created on `--range` (no column conditions). `range` is a separate flag (do not duplicate inside this JSON)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter rule JSON: `rules` and `filtered_columns?`; update overwrites the entire rule set (pass `rules: []` to clear). `range` is a separate flag", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-create": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by filter-view reference_id (returns the matching single view)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+filter-view-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "view-id", Kind: "own", Type: "string", Required: "required", Desc: "Target filter-view reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?`, `filtered_columns?`; update overwrites the entire rule set (read back with `+filter-view-list` first, then patch; pass `rules: []` to clear). `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; omit to keep the current range on update"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-create": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
{Name: "image", Kind: "own", Type: "string", Required: "xor", Desc: "Local image path; the CLI uploads it as a sheet_image and uses the returned file_token (XOR with --image-token / --image-uri)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id; lists all float images on the sheet when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+float-image-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "float-image-id", Kind: "own", Type: "string", Required: "required", Desc: "Target float image id"},
{Name: "image-name", Kind: "own", Type: "string", Required: "required", Desc: "Image name, including extension (e.g. `logo.png`)"},
{Name: "image-token", Kind: "own", Type: "string", Required: "xor", Desc: "Image file_token (XOR with `--image-uri`). Common source: `image_token` returned by `+float-image-list`"},
{Name: "image-uri", Kind: "own", Type: "string", Required: "xor", Desc: "Image reference_id (XOR with `--image-token`); the reference_id returned by the image upload flow"},
{Name: "position-row", Kind: "own", Type: "int", Required: "required", Desc: "Row anchor of the image's top-left corner (0-based)"},
{Name: "position-col", Kind: "own", Type: "string", Required: "required", Desc: "Column anchor of the image's top-left corner (column letter, e.g. `A` / `B`)"},
{Name: "size-width", Kind: "own", Type: "int", Required: "required", Desc: "Image width in pixels"},
{Name: "size-height", Kind: "own", Type: "int", Required: "required", Desc: "Image height in pixels"},
{Name: "offset-row", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor row, on top of `--position-row`"},
{Name: "offset-col", Kind: "own", Type: "int", Required: "optional", Desc: "Pixel offset within the anchor column, on top of `--position-col`"},
{Name: "z-index", Kind: "own", Type: "int", Required: "optional", Desc: "Image z-index controlling stacking order"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": {
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: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: {\"rows\":[...],\"columns\":[...],\"values\":[...],\"filters\":[...],\"show_row_grand_total\":true,\"show_col_grand_total\":true} (data source goes through --source; do not put source here)", Input: []string{"file", "stdin"}},
{Name: "target-position", Kind: "own", Type: "string", Required: "optional", Desc: "Top-left cell within the target sub-sheet (A1 notation, e.g. `A1`); maps to the top-level `target_position`, default `A1` (not sent when the value is A1). It and `--range` both express placement but map to different wire fields — avoid passing conflicting values for both.", Default: "A1"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "xor", Desc: "Reference_id of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-name`; takes priority when both given; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
{Name: "target-sheet-name", Kind: "own", Type: "string", Required: "xor", Desc: "Name of the target sub-sheet where the pivot table will be placed (mutually exclusive with `--target-sheet-id`; when both omitted, a new sub-sheet is auto-created to host the pivot — recommended). Distinct from the data-source sheet, which lives inside --source as a 'Sheet'-prefixed A1 reference like 'Sheet1'!A1:D100."},
{Name: "source", Kind: "own", Type: "string", Required: "required", Desc: "Pivot table source range (A1 notation; format `'SheetName'!StartCell:EndCell`, e.g. `'Sheet1'!A1:D100`)"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Pivot table top-left placement (single A1 value, e.g. `F1`; create only), maps to `properties.range`; placed at the top-left of the target sub-sheet (a newly created one by default) when omitted. It and `--target-position` both express placement but map to different wire fields — avoid passing conflicting values for both."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "pivot-table-id", Kind: "own", Type: "string", Required: "required", Desc: "Target pivot table id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete pivot config (read back with `+pivot-list --pivot-table-id <id>` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-copy": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
{Name: "paste-type", Kind: "own", Type: "string", Required: "optional", Desc: "Paste content type (`+range-copy` only)", Default: "all", Enum: []string{"values", "formulas", "formats", "all"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-fill": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Fill template range (seed cells for the series)"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination fill range (A1 notation)"},
{Name: "series-type", Kind: "own", Type: "string", Required: "optional", Desc: "Fill series type", Default: "auto", Enum: []string{"auto", "linear", "growth", "date", "copy"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-move": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "source-range", Kind: "own", Type: "string", Required: "required", Desc: "Source A1 range"},
{Name: "target-sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Destination sub-sheet id; defaults to the same sheet as the source"},
{Name: "target-range", Kind: "own", Type: "string", Required: "required", Desc: "Destination A1 range (anchor cell is enough; size inferred from the source)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+range-sort": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Sort range (A1 notation; whether the header is included depends on `--has-header`)"},
{Name: "sort-keys", Kind: "own", Type: "string", Required: "required", Desc: "JSON array: `[{\"column\":\"<col letter>\",\"ascending\":<bool>}, ...]`", Input: []string{"file", "stdin"}},
{Name: "has-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as a header and exclude from sort; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
{Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-copy": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Copy title; auto-generated by the server when omitted"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position for the copy (0-based); appended to the end when omitted", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-create": {
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: "title", Kind: "own", Type: "string", Required: "required", Desc: "New sheet title"},
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position; appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-hide": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-info": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated structure info categories to return", Enum: []string{"merges", "row_heights", "col_widths", "hidden_rows", "hidden_cols", "groups", "frozen"}},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "Limit structure info to this A1 range; whole sheet when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-move": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-rename": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "New title"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-set-tab-color": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "color", Kind: "own", Type: "string", Required: "required", Desc: "Hex color like `#FF0000`; pass empty string `\"\"` to clear"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sheet-unhide": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-create": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config (shared style), sparklines (array of mini-charts)}`; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-delete": {
Risk: "high-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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); delete is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-list": {
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: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "optional", Desc: "Filter by group_id"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+sparkline-update": {
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: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "group-id", Kind: "own", Type: "string", Required: "required", Desc: "Target group id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "JSON: `{config, sparklines}`; read back with `+sparkline-list --group-id <id>` first, then patch; run `--print-schema` for the full structure", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-create": {
Risk: "write",
Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{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: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-export": {
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: "file-extension", Kind: "own", Type: "string", Required: "optional", Desc: "Export file format; `csv` mode requires `--sheet-id`", Default: "xlsx", Enum: []string{"xlsx", "csv"}},
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Required only in csv mode: which sheet to export as CSV. This is a `+workbook-export`-specific flag, unrelated to the common four-tuple sheet locator (this shortcut does not accept the common sheet locator)"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Local save path; export is triggered but not downloaded when omitted"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+workbook-info": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
}

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
_ "embed"
"encoding/json"
"reflect"
"testing"
)
// flagDefsJSONForTest embeds the source data only in tests; production code
// reads the compiled flagDefs map (flag_defs_gen.go) and never unmarshals.
//
//go:embed data/flag-defs.json
var flagDefsJSONForTest []byte
// TestFlagDefsGen_MatchesJSON guards against drift between the compiled
// flagDefs map (flag_defs_gen.go) and its source data/flag-defs.json: if the
// JSON is regenerated without re-running the codegen (or vice versa), this
// fails. This equivalence is exactly what lets production code skip the
// runtime unmarshal.
func TestFlagDefsGen_MatchesJSON(t *testing.T) {
t.Parallel()
var fromJSON map[string]commandDef
if err := json.Unmarshal(flagDefsJSONForTest, &fromJSON); err != nil {
t.Fatalf("unmarshal flag-defs.json: %v", err)
}
if !reflect.DeepEqual(fromJSON, flagDefs) {
t.Error("compiled flagDefs differs from data/flag-defs.json; regenerate flag_defs_gen.go")
}
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"testing"
"github.com/larksuite/cli/shortcuts/common"
)
// TestFlagDefs_EmbedParses asserts the embedded flag-defs.json blob is valid
// JSON with at least one command entry.
func TestFlagDefs_EmbedParses(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatalf("loadFlagDefs error: %v", err)
}
if len(defs) == 0 {
t.Fatal("flag-defs.json has no command entries")
}
}
// TestFlagsFor_SkipsSystemFlags verifies system-kind flags (--dry-run, --yes)
// are never materialized into a shortcut's Flags slice — the framework injects
// those based on Risk / DryRun.
func TestFlagsFor_SkipsSystemFlags(t *testing.T) {
t.Parallel()
for _, cmd := range []string{"+sheet-delete", "+batch-update", "+csv-get"} {
for _, f := range flagsFor(cmd) {
if f.Name == "dry-run" || f.Name == "yes" {
t.Errorf("%s: system flag --%s leaked into Flags", cmd, f.Name)
}
}
}
}
// TestFlagsFor_MapsAllFields spot-checks that name/type/default/enum/input/
// required/hidden are carried over from the JSON correctly.
func TestFlagsFor_MapsAllFields(t *testing.T) {
t.Parallel()
byName := func(cmd, name string) *common.Flag {
flags := flagsFor(cmd)
for i := range flags {
if flags[i].Name == name {
return &flags[i]
}
}
return nil
}
// enum + default
rt := byName("+dim-insert", "inherit-style")
if rt == nil || len(rt.Enum) != 3 || rt.Default != "none" {
t.Errorf("+dim-insert --inherit-style not mapped: %+v", rt)
}
// required
title := byName("+sheet-create", "title")
if title == nil || !title.Required {
t.Errorf("+sheet-create --title should be required: %+v", title)
}
// xor is NOT cobra-required (enforced by Validate hooks)
url := byName("+sheet-create", "url")
if url == nil || url.Required {
t.Errorf("+sheet-create --url should not be cobra-required: %+v", url)
}
// hidden + int default
cap := byName("+cells-get", "max-chars")
if cap == nil || !cap.Hidden || cap.Default != "200000" {
t.Errorf("+cells-get --max-chars not mapped: %+v", cap)
}
// input sources
cells := byName("+cells-set", "cells")
if cells == nil || len(cells.Input) != 2 {
t.Errorf("+cells-set --cells should support file+stdin: %+v", cells)
}
// float64 type
fs := byName("+cells-set-style", "font-size")
if fs == nil || fs.Type != "float64" {
t.Errorf("+cells-set-style --font-size should be float64: %+v", fs)
}
}
// TestFlagsFor_EveryRegisteredCommandHasDefs ensures every shortcut returned by
// Shortcuts() has a flag-defs.json entry and that its flags match the JSON's
// non-system flags exactly (name + type + required + default + hidden). This is
// the contract that lets shortcuts drop hand-written flag literals.
func TestFlagsFor_EveryRegisteredCommandHasDefs(t *testing.T) {
t.Parallel()
defs, err := loadFlagDefs()
if err != nil {
t.Fatal(err)
}
for _, s := range Shortcuts() {
spec, ok := defs[s.Command]
if !ok {
t.Errorf("%s has no flag-defs.json entry", s.Command)
continue
}
want := map[string]flagDef{}
for _, df := range spec.Flags {
if df.Kind != "system" {
want[df.Name] = df
}
}
got := map[string]bool{}
for _, f := range s.Flags {
got[f.Name] = true
df, ok := want[f.Name]
if !ok {
t.Errorf("%s --%s present in Go but not in JSON (non-system)", s.Command, f.Name)
continue
}
ft := f.Type
if ft == "" {
ft = "string"
}
jt := df.Type
if jt == "" {
jt = "string"
}
if ft != jt {
t.Errorf("%s --%s type: go=%s json=%s", s.Command, f.Name, ft, jt)
}
if f.Required != (df.Required == "required") {
t.Errorf("%s --%s required: go=%v json=%s", s.Command, f.Name, f.Required, df.Required)
}
if f.Default != df.Default {
t.Errorf("%s --%s default: go=%q json=%q", s.Command, f.Name, f.Default, df.Default)
}
if f.Hidden != df.Hidden {
t.Errorf("%s --%s hidden: go=%v json=%v", s.Command, f.Name, f.Hidden, df.Hidden)
}
}
for name := range want {
if !got[name] {
t.Errorf("%s --%s in JSON but missing from Go Flags", s.Command, name)
}
}
}
}

View File

@@ -1,124 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
_ "embed"
"encoding/json"
"fmt"
"sort"
"sync"
)
// ─── --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 = fmt.Errorf("flag-schemas.json: %w", 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.
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, fmt.Errorf("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 the JSON Schema for that flag",
}, "", " ")
}
schema, ok := entry[flagName]
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
flags = append(flags, f)
}
sort.Strings(flags)
return nil, fmt.Errorf("no JSON Schema registered for %s --%s; available: %v", command, flagName, flags)
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
return json.MarshalIndent(pretty, "", " ")
}
}

Some files were not shown because too many files have changed in this diff Show More