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
289 changed files with 6830 additions and 41543 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/
@@ -73,20 +65,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/mail/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
# it bans are still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/|shortcuts/mail/)
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -115,17 +107,17 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Migrated domains use
# typed errs.* builders or domain-local file-I/O helpers instead; this
# prevents reintroduction while unmigrated domains continue to use the
# shared helpers until their later migration phase.
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use typed errs.NewXxxError builders or a domain-local
file-I/O helper.
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,43 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [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
@@ -1026,8 +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.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[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

@@ -1,160 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"sort"
"strings"
)
// universalFlags are accepted by every command (cobra auto-injects help; the
// root injects version). They are never reported as unknown.
var universalFlags = map[string]bool{"--help": true, "-h": true, "--version": true}
// catalog is the source-of-truth command catalog: command path -> accepted flag
// tokens. A path is the command words WITHOUT the "lark-cli" root prefix, e.g.
// "contact +search-user". The root command is the empty path "".
type catalog struct {
flagsByPath map[string]map[string]bool
group map[string]bool // paths that are parent groups (have subcommands)
sorted []string // cached sorted paths for suggestCommand; invalidated on addCommand
}
func newCatalog() *catalog {
return &catalog{
flagsByPath: map[string]map[string]bool{},
group: map[string]bool{},
}
}
// setGroup records whether path is a parent group (has subcommands). Leftover
// words after a group node are unknown subcommands; after a leaf they are
// positionals (e.g. "api GET /path").
func (c *catalog) setGroup(path string, isGroup bool) {
if isGroup {
c.group[path] = true
}
}
func (c *catalog) isGroup(path string) bool { return c.group[path] }
// addCommand registers a command path and the flags it accepts. Repeated calls
// for the same path union the flag sets. flags are full tokens ("--query", "-q").
func (c *catalog) addCommand(path string, flags []string) {
set := c.flagsByPath[path]
if set == nil {
set = map[string]bool{}
c.flagsByPath[path] = set
}
for _, f := range flags {
set[f] = true
}
c.sorted = nil // invalidate cached suggestion list
}
func (c *catalog) hasCommand(path string) bool {
_, ok := c.flagsByPath[path]
return ok
}
// hasFlag reports whether flag is accepted by command path (universal flags
// always pass).
func (c *catalog) hasFlag(path, flag string) bool {
if universalFlags[flag] {
return true
}
set := c.flagsByPath[path]
return set[flag]
}
// longestPrefix returns the longest known command path that is a prefix of
// words, plus how many words it consumed. This separates real subcommands from
// trailing positionals (e.g. "api GET /path" resolves to "api"). When words is
// empty it falls back to the root command. ok=false means not even the first
// word names a command.
func (c *catalog) longestPrefix(words []string) (path string, n int, ok bool) {
if len(words) == 0 {
if c.hasCommand("") {
return "", 0, true
}
return "", 0, false
}
for i := len(words); i >= 1; i-- {
cand := strings.Join(words[:i], " ")
if c.hasCommand(cand) {
return cand, i, true
}
}
return "", 0, false
}
// paths returns all known command paths, sorted.
func (c *catalog) paths() []string {
out := make([]string, 0, len(c.flagsByPath))
for p := range c.flagsByPath {
out = append(out, p)
}
sort.Strings(out)
return out
}
// suggestCommand returns the known command path closest to want (small edit
// distance), for error hints. Returns "" when nothing is reasonably close.
func (c *catalog) suggestCommand(want string) string {
if c.sorted == nil {
c.sorted = c.paths() // built once after the catalog is fully populated
}
return closest(want, c.sorted)
}
// suggestFlag returns the flag of path closest to flag, for error hints.
func (c *catalog) suggestFlag(path, flag string) string {
set := c.flagsByPath[path]
cands := make([]string, 0, len(set))
for f := range set {
cands = append(cands, f)
}
sort.Strings(cands)
return closest(flag, cands)
}
// closest returns the candidate with the smallest Levenshtein distance to want,
// but only if that distance is within a tolerance scaled to want's length
// (avoids absurd suggestions).
func closest(want string, cands []string) string {
best := ""
bestD := 1 << 30
for _, cand := range cands {
d := levenshtein(want, cand)
if d < bestD {
bestD, best = d, cand
}
}
tol := len(want)/2 + 1
if bestD > tol {
return ""
}
return best
}
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
prev := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
cur := make([]int, len(rb)+1)
cur[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, cur[j-1]+1, prev[j-1]+cost)
}
prev = cur
}
return prev[len(rb)]
}

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import "strings"
// Finding kinds.
const (
unknownCommand = "unknown_command"
unknownFlag = "unknown_flag"
)
// finding is a single mismatch between an example command reference and the
// catalog.
type finding struct {
line int
raw string
kind string // unknownCommand | unknownFlag
path string // resolved command path (unknownFlag) or attempted path (unknownCommand)
flag string // offending flag (unknownFlag only)
suggest string // nearest known command/flag, "" if none close
}
// checkRefs validates refs against cat and returns all mismatches in order.
func checkRefs(cat *catalog, refs []ref) []finding {
var out []finding
for _, r := range refs {
path, n, ok := cat.longestPrefix(r.words)
if !ok {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
// Leftover words after a group node are an unknown subcommand (e.g. a
// mistyped method like "batch_modify_message"). After a leaf they are
// positionals (e.g. "api GET /path"), so only groups trigger this.
if n < len(r.words) && cat.isGroup(path) {
attempted := strings.Join(r.words, " ")
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownCommand,
path: attempted, suggest: cat.suggestCommand(attempted),
})
continue
}
for _, f := range r.flags {
if cat.hasFlag(path, f) {
continue
}
out = append(out, finding{
line: r.line, raw: r.raw, kind: unknownFlag,
path: path, flag: f, suggest: cat.suggestFlag(path, f),
})
}
}
return out
}

View File

@@ -1,222 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"regexp"
"strings"
)
// ref is one lark-cli command reference extracted from a shortcut example.
type ref struct {
line int // 1-based line number (the line where the command starts)
raw string // reconstructed command text, for error display
words []string // command words before the first flag (subcommand candidates)
flags []string // flag tokens used, e.g. "--query", "-q"
}
const cliToken = "lark-cli"
// subcommandStart guards against false positives from prose: a real command's
// first word is ASCII (a service name or a +shortcut). A token starting with
// CJK / punctuation is treated as narration, not a command.
var subcommandStart = regexp.MustCompile(`^[A-Za-z+]`)
// shellStops are standalone tokens that terminate a command (pipes, redirects,
// separators). Separators glued to a token (`get;`, `foo|`) are handled inline.
var shellStops = map[string]bool{
"|": true, "||": true, "&&": true, "&": true, ";": true,
">": true, ">>": true, "<": true, "2>": true, "2>&1": true,
}
// wordTrailPunct is sentence / CJK punctuation that can cling to a command word
// in prose ("auth login." / "auth login"); stripped so the word still resolves
// instead of being dropped as an unknown command or non-ASCII narration.
const wordTrailPunct = `.,;:!?"')]},。、;:!?)】」』`
// parseRefs extracts every lark-cli command reference from text (a shortcut's
// Tips line, which may embed an "Example: lark-cli ..." command). It is
// deliberately format-agnostic: it keys on the "lark-cli" token whether it sits
// in a ```bash fence, an inline `code` span, or bare prose. Backslash
// line-continuations are joined first so a multi-line invocation is parsed as
// one command; inline-code backticks and trailing # comments terminate it.
func parseRefs(content string) []ref {
var refs []ref
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
lineNo := i + 1
logical := lines[i]
// Shell line continuation: a trailing backslash joins the next physical
// line. Without this, flags on the continuation lines of a multi-line
// `lark-cli ... \` example are never seen by the checker.
for endsWithBackslash(logical) && i+1 < len(lines) {
logical = strings.TrimRight(logical, " \t")
logical = logical[:len(logical)-1] // drop the trailing backslash
i++
logical += " " + lines[i]
}
refs = append(refs, parseLine(logical, lineNo)...)
}
return refs
}
func endsWithBackslash(s string) bool {
return strings.HasSuffix(strings.TrimRight(s, " \t"), `\`)
}
func parseLine(line string, lineNo int) []ref {
var refs []ref
rest := line
for {
idx := strings.Index(rest, cliToken)
if idx < 0 {
break
}
after := rest[idx+len(cliToken):]
beforeOK := idx == 0 || isBoundary(rest[idx-1])
afterOK := after == "" || isBoundary(after[0])
if beforeOK && afterOK {
if words, flags, raw, ok := parseCmd(after); ok {
refs = append(refs, ref{line: lineNo, raw: cliToken + raw, words: words, flags: flags})
}
}
rest = after
}
return refs
}
// parseCmd tokenizes the text following "lark-cli" into leading command words
// (the subcommand path, up to the first flag) and flag tokens. It stops at a
// shell separator (standalone or glued), an inline-code backtick, a comment, or
// a placeholder/prose word. ok=false filters out non-commands.
func parseCmd(after string) (words, flags []string, raw string, ok bool) {
// An inline code span ends at the next backtick; a command never spans one.
if i := strings.IndexByte(after, '`'); i >= 0 {
after = after[:i]
}
// Drop $(...) command substitutions so flags belonging to the inner command
// (e.g. `--data "$(jq -n --arg x ...)"`) are not mistaken for lark-cli flags.
after = stripCmdSubst(after)
var kept []string
inFlags := false
for _, orig := range strings.Fields(after) {
tok := orig
if shellStops[tok] || strings.HasPrefix(tok, "#") {
break
}
// A shell separator glued to a token ends the command mid-token
// ("get;", "foo|next"): keep the part before it, handle it, then stop.
stop := false
if i := strings.IndexAny(tok, ";|"); i >= 0 {
tok, stop = tok[:i], true
}
switch {
case tok == "" || tok == "-":
// empty (after a glued separator) or a bare stdin marker — skip
case strings.HasPrefix(tok, "-"):
if f := normalizeFlag(tok); f != "" {
inFlags = true
flags = append(flags, f)
kept = append(kept, tok)
}
case inFlags:
// positional / flag value after the first flag — not a command word
kept = append(kept, tok)
default:
// Command-path word. ASCII placeholder markers (<x>, [x], {x|y},
// +<verb>, ...) end the command — checked on the RAW token so the
// trailing-punct stripping below cannot erase a "..." ellipsis
// ("base +..." must stay a placeholder, not become "+").
if strings.ContainsAny(tok, "<>[]{}|") || strings.Contains(tok, "...") {
stop = true
break
}
// Strip trailing sentence/CJK punctuation so "login." / "login"
// resolve to "login"; non-ASCII narration ends the command.
w := strings.TrimRight(tok, wordTrailPunct)
if w == "" || hasNonASCII(w) {
stop = true
break
}
words = append(words, w)
kept = append(kept, tok)
}
if stop {
break
}
}
if len(kept) > 0 {
raw = " " + strings.Join(kept, " ")
}
// Keep root-only refs ("lark-cli --help") and refs whose first word looks
// like a subcommand; drop prose ("lark-cli 就能搞定 ...").
if len(words) == 0 {
return words, flags, raw, len(flags) > 0
}
if !subcommandStart.MatchString(words[0]) {
return nil, nil, "", false
}
return words, flags, raw, true
}
// stripCmdSubst removes $(...) command substitutions (including nested ones)
// from s, leaving the surrounding text intact. Backtick substitutions are
// already handled upstream (a command never spans a backtick).
func stripCmdSubst(s string) string {
var b strings.Builder
depth := 0
for i := 0; i < len(s); i++ {
if depth == 0 && i+1 < len(s) && s[i] == '$' && s[i+1] == '(' {
depth = 1
i++ // skip '('
continue
}
if depth > 0 {
switch s[i] {
case '(':
depth++
case ')':
depth--
}
continue
}
b.WriteByte(s[i])
}
return b.String()
}
// isPlaceholderOrProse reports whether a command word is a doc placeholder
// (<resource>, [flags], {a|b}, +<verb>, ...) or narration (CJK / other
// non-ASCII), rather than a literal command token.
func isPlaceholderOrProse(w string) bool {
if hasNonASCII(w) {
return true
}
return strings.ContainsAny(w, "<>[]{}|") || strings.Contains(w, "...")
}
func hasNonASCII(s string) bool {
return strings.IndexFunc(s, func(r rune) bool { return r > 127 }) >= 0
}
// flagShape matches the leading flag token, stripping any trailing junk such as
// a "=value" suffix or punctuation that bled in from the surrounding markdown
// ("--help\"", "--help;", "--params={}"). The underscore is allowed because
// real flags use it ("--input_format", "--output_as"). Returns "" for non-flags.
var flagShape = regexp.MustCompile(`^--?[A-Za-z][A-Za-z0-9_-]*`)
// normalizeFlag extracts the canonical flag token from tok, or "" if tok is not
// a real flag (e.g. a shell-string fragment like "-草稿'").
func normalizeFlag(tok string) string {
return flagShape.FindString(tok)
}
func isBoundary(b byte) bool {
switch b {
case ' ', '\t', '`', '(', ')', '\'', '"', '*':
return true
}
return false
}

View File

@@ -1,113 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// This file and its cmdexample_*_test.go siblings implement a test-only check:
// the example commands embedded in shortcut definitions (the "Example: lark-cli
// ..." lines in each shortcut's Tips, shown in --help) must match the real
// command tree. It lives entirely in _test.go files (package cmd_test) so it
// ships in no binary and is not importable by product code; the truth source is
// cmd.Build, the same tree the binary uses, so the check cannot drift.
//
// It runs in the standard unit-test CI job (go test ./cmd/...). A mismatch — an
// example using a renamed command or an unaccepted flag — fails that job.
package cmd_test
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// TestShortcutExampleCommands checks the example commands embedded in every
// shortcut's Tips against the live command tree. A shortcut that defines no
// example is simply skipped.
//
// Because the examples and the command definitions live in the same Go code,
// this is a self-consistency check: any mismatch (an example using a renamed
// command or a flag the command doesn't accept) is a bug to fix at the source.
// It runs over all shortcuts — no baseline, no diff — since a wrong example is
// always a defect, never acceptable "pre-existing drift".
func TestShortcutExampleCommands(t *testing.T) {
// Reproducibility: use the embedded API metadata (not a developer's stale
// ~/.lark-cli remote cache, which can miss commands) and an empty config
// dir so local strict mode / plugins / policy cannot reshape the tree.
// t.Setenv auto-restores after the test, so other cmd tests are unaffected.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cat := buildCmdExampleCatalog()
type located struct {
shortcut string
f finding
}
var findings []located
for _, sc := range shortcuts.AllShortcuts() {
var refs []ref
for _, tip := range sc.Tips {
refs = append(refs, parseRefs(tip)...)
}
label := strings.TrimSpace(sc.Service + " " + sc.Command)
for _, f := range checkRefs(cat, refs) {
findings = append(findings, located{shortcut: label, f: f})
}
}
if len(findings) == 0 {
return
}
sort.Slice(findings, func(i, j int) bool { return findings[i].shortcut < findings[j].shortcut })
for _, lf := range findings {
hint := ""
if lf.f.suggest != "" {
hint = " (did you mean " + lf.f.suggest + "?)"
}
if lf.f.kind == unknownFlag {
t.Errorf("shortcut %q example uses unknown flag %s on %q%s\n %s",
lf.shortcut, lf.f.flag, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
} else {
t.Errorf("shortcut %q example uses unknown command %q%s\n %s",
lf.shortcut, lf.f.path, hint, strings.TrimSpace(lf.f.raw))
}
}
t.Fatalf("%d shortcut example command(s) don't match the real CLI — "+
"fix the Example in the shortcut definition.", len(findings))
}
// buildCmdExampleCatalog walks the live cobra command tree and records every
// command path (minus the "lark-cli" root prefix) with its accepted flags and
// whether it is a parent group. This is the same Build() the binary uses, so
// the catalog can never drift from the real commands.
func buildCmdExampleCatalog() *catalog {
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
cat := newCatalog()
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
var flags []string
add := func(fl *pflag.Flag) {
flags = append(flags, "--"+fl.Name)
if fl.Shorthand != "" {
flags = append(flags, "-"+fl.Shorthand)
}
}
c.Flags().VisitAll(add)
c.InheritedFlags().VisitAll(add)
c.PersistentFlags().VisitAll(add) // root's own persistent flags (e.g. --profile)
cat.addCommand(path, flags)
cat.setGroup(path, c.HasSubCommands())
for _, sub := range c.Commands() {
walk(sub)
}
}
walk(root)
return cat
}

View File

@@ -1,233 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd_test
import (
"strings"
"testing"
)
func testCatalog() *catalog {
c := newCatalog()
c.addCommand("", []string{"--profile"}) // root
c.setGroup("", true)
c.addCommand("contact", []string{"--profile"})
c.setGroup("contact", true)
c.addCommand("contact +search-user", []string{"--query", "--as", "--format", "-q"})
c.addCommand("api", []string{"--params", "--data", "--as"}) // leaf (no subcommands)
c.addCommand("mail", nil)
c.setGroup("mail", true)
c.addCommand("mail user_mailbox.messages", []string{"--profile"})
c.setGroup("mail user_mailbox.messages", true)
c.addCommand("mail user_mailbox.messages batch_modify", []string{"--params", "--data"})
return c
}
func TestCmdExampleCatalogHasCommandAndFlag(t *testing.T) {
c := testCatalog()
if !c.hasCommand("contact +search-user") {
t.Fatal("expected contact +search-user to exist")
}
if c.hasCommand("contact +nope") {
t.Fatal("did not expect contact +nope")
}
if !c.hasFlag("contact +search-user", "--query") {
t.Fatal("--query should be valid")
}
if c.hasFlag("contact +search-user", "--nope") {
t.Fatal("--nope should be invalid")
}
// universal flags pass on any command
for _, f := range []string{"--help", "-h", "--version"} {
if !c.hasFlag("contact +search-user", f) {
t.Fatalf("universal flag %s should pass", f)
}
}
}
func TestCmdExampleLongestPrefix(t *testing.T) {
c := testCatalog()
tests := []struct {
words []string
want string
wantN int
wantOK bool
}{
{[]string{"contact", "+search-user"}, "contact +search-user", 2, true},
{[]string{"api", "GET", "/open-apis/x"}, "api", 1, true}, // trailing positionals
{[]string{"nope"}, "", 0, false},
{nil, "", 0, true}, // empty -> root
}
for _, tt := range tests {
got, n, ok := c.longestPrefix(tt.words)
if got != tt.want || n != tt.wantN || ok != tt.wantOK {
t.Errorf("longestPrefix(%v) = (%q,%d,%v), want (%q,%d,%v)",
tt.words, got, n, ok, tt.want, tt.wantN, tt.wantOK)
}
}
}
func refWordsOf(refs []ref) [][]string {
var out [][]string
for _, r := range refs {
out = append(out, r.words)
}
return out
}
func TestCmdExampleParseRefsExtractsCommands(t *testing.T) {
content := strings.Join([]string{
"运行 `lark-cli contact +search-user --query 张三` 搜索", // inline code
"```bash",
"lark-cli api GET /open-apis/x --params '{}'", // bash block
"```",
"用 lark-cli mail user_mailbox.messages batch_modify 即可", // bare prose command
"npx foo | lark-cli api GET /y", // after a pipe
}, "\n")
refs := parseRefs(content)
if len(refs) != 4 {
t.Fatalf("expected 4 refs, got %d: %v", len(refs), refWordsOf(refs))
}
if got := refs[0]; strings.Join(got.words, " ") != "contact +search-user" ||
len(got.flags) != 1 || got.flags[0] != "--query" {
t.Errorf("ref0 = %+v", got)
}
if got := refs[1]; strings.Join(got.words, " ") != "api GET /open-apis/x" {
t.Errorf("ref1 words = %v", got.words)
}
}
func TestCmdExampleParseRefsFiltersPlaceholdersAndProse(t *testing.T) {
// A line whose first word is prose yields no command at all.
if refs := parseRefs("lark-cli 就能搞定这件事"); len(refs) != 0 {
t.Errorf("prose-first line should yield 0 refs, got %v", refWordsOf(refs))
}
// Syntax templates / trailing prose may leave a real leading word ("mail"),
// but no placeholder or CJK token may leak into the command words — that is
// what prevents false positives like an "<resource>" unknown-command report.
for _, line := range []string{
"lark-cli mail <resource> <method> [flags]",
"lark-cli apps +<verb> [flags]",
"lark-cli base +...",
"lark-cli mail 写信场景下的格式说明",
} {
for _, r := range parseRefs(line) {
for _, w := range r.words {
if isPlaceholderOrProse(w) {
t.Errorf("%q: placeholder/prose token %q leaked into words %v", line, w, r.words)
}
}
}
}
}
func TestCmdExampleParseRefsStripsTrailingJunk(t *testing.T) {
// frontmatter-style quoted value: the trailing quote must not bleed into the flag
refs := parseRefs(`cliHelp: "lark-cli contact --help"`)
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
if len(refs[0].flags) != 1 || refs[0].flags[0] != "--help" {
t.Errorf("expected flag --help, got %v", refs[0].flags)
}
// bare "-" (stdin marker) and "=value" suffix
refs = parseRefs("lark-cli api GET /x --params={} --data -")
if len(refs) != 1 {
t.Fatalf("expected 1 ref, got %d", len(refs))
}
flags := strings.Join(refs[0].flags, " ")
if flags != "--params --data" {
t.Errorf("expected '--params --data', got %q", flags)
}
}
func TestCmdExampleCheck(t *testing.T) {
c := testCatalog()
tests := []struct {
name string
r ref
wantKind string // "" = no finding
wantPath string
}{
{"valid shortcut", ref{words: []string{"contact", "+search-user"}, flags: []string{"--query"}}, "", ""},
{"valid leaf positional", ref{words: []string{"api", "GET", "/x"}}, "", ""},
{"unknown top command", ref{words: []string{"nope"}}, unknownCommand, "nope"},
{"group leftover = unknown subcommand",
ref{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}},
unknownCommand, "mail user_mailbox.messages batch_modify_message"},
{"unknown flag", ref{words: []string{"contact", "+search-user"}, flags: []string{"--nope"}}, unknownFlag, "contact +search-user"},
{"universal flag ok", ref{words: []string{"contact", "+search-user"}, flags: []string{"--help"}}, "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := checkRefs(c, []ref{tt.r})
if tt.wantKind == "" {
if len(fs) != 0 {
t.Fatalf("expected no finding, got %+v", fs)
}
return
}
if len(fs) != 1 {
t.Fatalf("expected 1 finding, got %d: %+v", len(fs), fs)
}
if fs[0].kind != tt.wantKind || fs[0].path != tt.wantPath {
t.Errorf("got kind=%s path=%q, want kind=%s path=%q", fs[0].kind, fs[0].path, tt.wantKind, tt.wantPath)
}
})
}
}
func TestCmdExampleCheckSuggestsNearest(t *testing.T) {
c := testCatalog()
fs := checkRefs(c, []ref{{words: []string{"mail", "user_mailbox.messages", "batch_modify_message"}}})
if len(fs) != 1 || fs[0].suggest != "mail user_mailbox.messages batch_modify" {
t.Fatalf("expected suggestion 'mail user_mailbox.messages batch_modify', got %+v", fs)
}
}
// TestCmdExampleParseRefsRobustness covers the parser edge cases hardened after
// review: backslash continuation, underscore flags, $(...) substitution, glued
// separators, trailing punctuation, and the "..." placeholder.
func TestCmdExampleParseRefsRobustness(t *testing.T) {
cases := []struct {
name, content, wantWords, wantFlags string
wantRefs int
}{
{"backslash continuation joins flags",
"lark-cli contact +search-user \\\n --query foo \\\n --as user",
"contact +search-user", "--query --as", 1},
{"underscore flag not truncated",
"lark-cli whiteboard +update --input_format mermaid",
"whiteboard +update", "--input_format", 1},
{"command-substitution flags ignored",
`lark-cli slides x create --data "$(jq -n --arg c '{}')" --as user`,
"slides x create", "--data --as", 1},
{"glued separator truncates",
"lark-cli auth login; echo done",
"auth login", "", 1},
{"trailing CJK punctuation stripped",
"用 lark-cli auth login。",
"auth login", "", 1},
{"ellipsis placeholder stays placeholder",
"lark-cli base +...",
"base", "", 1},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
refs := parseRefs(tt.content)
if len(refs) != tt.wantRefs {
t.Fatalf("refs=%d want %d: %v", len(refs), tt.wantRefs, refWordsOf(refs))
}
if tt.wantRefs == 0 {
return
}
if got := strings.Join(refs[0].words, " "); got != tt.wantWords {
t.Errorf("words=%q want %q", got, tt.wantWords)
}
if got := strings.Join(refs[0].flags, " "); got != tt.wantFlags {
t.Errorf("flags=%q want %q", got, tt.wantFlags)
}
})
}
}

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)
}
}

View File

@@ -61,8 +61,6 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:

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

@@ -244,8 +244,6 @@ func APIHint(subtype errs.Subtype) string {
return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// mailCodeMeta holds mail-service Lark code -> CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
var mailCodeMeta = map[int]CodeMeta{
1234013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // mailbox not found or not active
1236007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily send count exceeded
1236008: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // user daily external recipient count exceeded
1236009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant daily external recipient count exceeded
1236010: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // mail quota limit
1236013: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tenant storage limit exceeded
}
func init() { mergeCodeMeta(mailCodeMeta, "mail") }

View File

@@ -165,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

View File

@@ -188,13 +188,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills ls -g",
},
{
name: "list global json",
run: func(u *Updater) *NpmResult {
return u.ListGlobalSkillsJSON()
},
want: "-y skills ls -g --json",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {

View File

@@ -4,7 +4,6 @@
package skillscheck
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -58,28 +57,6 @@ func ParseSkillsList(text string) []string {
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type globalSkill struct {
Name string `json:"name"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil
}
seen := map[string]bool{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
}
return sortedKeys(seen)
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -100,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
continue
}
if strings.HasPrefix(trimmed, "Agents:") {
continue
}
if isGlobalSkillsSectionHeader(trimmed) {
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}
@@ -117,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0]
// Validate and add
if candidate == "" || !skillNamePattern.MatchString(candidate) {
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func isGlobalSkillsSectionHeader(line string) bool {
switch line {
case "General", "Project", "Local":
return true
default:
return false
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -224,7 +195,6 @@ func PlanSync(input SyncInput) SyncPlan {
type SkillsRunner interface {
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
@@ -269,9 +239,10 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
}
// --- Step 3: Read previous state ---
@@ -327,24 +298,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
textResult := runner.ListGlobalSkills()
if textResult != nil && textResult.Err == nil {
if local := ParseSkillsList(textResult.Stdout.String()); len(local) > 0 {
return local, true
}
}
return nil, false
}
// fallbackFullInstall performs a full skills install (npx -y skills add <source> -g -y)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -67,49 +67,6 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
}
}
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
input := `Global Skills
General
lark-apps ~/.agents/skills/lark-apps
lark-base ~/.agents/skills/lark-base
`
got := ParseSkillsList(input)
want := []string{"lark-apps", "lark-base"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSON(t *testing.T) {
input := `[
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
{"name":""},
{"name":" "},
{"name":"bad skill"}
]`
got := ParseGlobalSkillsJSON(input)
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`{"name":"lark-calendar"}`,
`[]`,
} {
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -156,18 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialOut string
globalJSONOut string
globalOut string
officialErr error
globalJSONErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
listedGlobalJSON int
listedGlobalText int
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
}
func officialSkillsOutput(names ...string) string {
@@ -193,19 +146,6 @@ func globalSkillsOutput(names ...string) string {
return b.String()
}
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
@@ -213,16 +153,7 @@ func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
return r
}
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
@@ -255,9 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -269,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState()
if err != nil || !readable {
@@ -338,73 +262,47 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
}
}
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
}
}
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if strings.Contains(result.Detail, "/Users/example") || strings.Contains(result.Detail, "agents") {
t.Fatalf("SyncSkills() detail leaks local command output: %q", result.Detail)
}
}
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
}
}
@@ -446,7 +344,6 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -478,7 +375,6 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
@@ -577,7 +473,6 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -602,7 +497,6 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
@@ -643,9 +537,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

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,139 +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/",
"shortcuts/mail/",
}
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

@@ -17,7 +17,6 @@ import (
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/drive/",
"shortcuts/mail/",
}
// legacyOutputImportPath is the import path of the package that declares the

View File

@@ -877,129 +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",
}
paths := []string{
"shortcuts/drive/drive_search.go",
"shortcuts/mail/mail_send.go",
}
for _, path := range paths {
for _, helper := range helpers {
t.Run(path+"_"+helper, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.` + helper + `()
}
`
v := CheckNoLegacyCommonHelperCall(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, 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.48",
"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

@@ -15,7 +15,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
)
@@ -58,7 +57,6 @@ type DriveMediaMultipartUploadConfig struct {
Reader io.Reader
}
// Deprecated: use UploadDriveMediaAllTyped for typed error envelopes.
func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
@@ -100,52 +98,6 @@ func UploadDriveMediaAll(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig)
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadAllAction)
}
// UploadDriveMediaAllTyped is the typed-error counterpart of
// UploadDriveMediaAll: file-open failures surface as typed validation errors,
// transport failures as typed network errors, and API failures are classified
// via ClassifyAPIResponse so subtype / code / log_id survive on the error.
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
var fileReader io.Reader
if cfg.Reader != nil {
fileReader = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return "", WrapInputStatErrorTyped(err)
}
defer f.Close()
fileReader = f
}
fd := larkcore.NewFormdata()
fd.AddField("file_name", cfg.FileName)
fd.AddField("parent_type", cfg.ParentType)
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
if cfg.ParentNode != nil {
fd.AddField("parent_node", *cfg.ParentNode)
}
if cfg.Extra != "" {
fd.AddField("extra", cfg.Extra)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
}
// Deprecated: use UploadDriveMediaMultipartTyped for typed error envelopes.
func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
@@ -178,43 +130,6 @@ func UploadDriveMediaMultipart(runtime *RuntimeContext, cfg DriveMediaMultipartU
return finishDriveMediaMultipartUpload(runtime, session.UploadID, session.BlockNum)
}
// UploadDriveMediaMultipartTyped is the typed-error counterpart of
// UploadDriveMediaMultipart: prepare/finish failures come back typed from
// CallAPITyped, malformed session plans surface as invalid-response internal
// errors, and per-part transport/API failures are classified the same way as
// UploadDriveMediaAllTyped.
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
// upload_prepare expects parent_node to be present even when the caller wants
// the service default/root behavior, so multipart callers pass an explicit
// string instead of relying on field omission like upload_all does.
prepareBody := map[string]interface{}{
"file_name": cfg.FileName,
"parent_type": cfg.ParentType,
"parent_node": cfg.ParentNode,
"size": cfg.FileSize,
}
if cfg.Extra != "" {
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
if err != nil {
return "", err
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", err
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
}
func ParseDriveMediaMultipartUploadSession(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
// The backend chooses both chunk size and chunk count. Validate them once so
// the streaming loop can follow the returned plan without re-checking shape.
@@ -365,122 +280,3 @@ func finishDriveMediaMultipartUpload(runtime *RuntimeContext, uploadID string, b
}
return ExtractDriveMediaUploadFileToken(data, driveMediaUploadFinishAction)
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
// message so callers see which upload step failed. Non-typed errors are
// returned unchanged.
func prefixDriveMediaUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
}
return err
}
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
// session plan like ParseDriveMediaMultipartUploadSession, but reports a
// malformed plan as a typed invalid-response internal error.
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
session := DriveMediaMultipartUploadSession{
UploadID: GetString(data, "upload_id"),
BlockSize: int64(GetFloat(data, "block_size")),
BlockNum: int(GetFloat(data, "block_num")),
}
if session.UploadID == "" {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
}
if session.BlockSize <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
if session.BlockNum <= 0 {
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
}
return session, nil
}
// extractDriveMediaUploadFileTokenTyped mirrors ExtractDriveMediaUploadFileToken
// with a typed invalid-response internal error for a missing file_token.
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
fileToken := GetString(data, "file_token")
if fileToken == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
}
return fileToken, nil
}
// uploadDriveMediaMultipartPartsTyped mirrors uploadDriveMediaMultipartParts
// with typed errors for file-open, file-read, and per-part upload failures.
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
var r io.Reader
if cfg.Reader != nil {
r = cfg.Reader
} else {
f, err := runtime.FileIO().Open(cfg.FilePath)
if err != nil {
return WrapInputStatErrorTyped(err)
}
defer f.Close()
r = f
}
maxInt := int64(^uint(0) >> 1)
bufferSize := session.BlockSize
if bufferSize <= 0 || bufferSize > maxInt {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(bufferSize))
remaining := cfg.FileSize
// Follow the server-declared block plan exactly; upload_finish expects the
// same block count returned by upload_prepare.
for seq := 0; seq < session.BlockNum; seq++ {
chunkSize := session.BlockSize
if remaining > 0 && chunkSize > remaining {
chunkSize = remaining
}
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
if readErr != nil {
return WrapInputStatErrorTyped(readErr)
}
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
remaining -= int64(n)
}
return nil
}
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", uploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
fd.AddFile("file", bytes.NewReader(chunk))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
}
return nil
}
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})
if err != nil {
return "", err
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
}

View File

@@ -1,305 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestUploadDriveMediaAllTypedWithInMemoryContent(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_123"},
},
})
payload := []byte{0x89, 0x50, 0x4e, 0x47}
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
}
if fileToken != "file_typed_123" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_123")
}
}
func TestUploadDriveMediaAllTypedClassifiesAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 999,
"msg": "upload rejected",
},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI {
t.Fatalf("category = %s, want api", p.Category)
}
if p.Code != 999 {
t.Fatalf("code = %d, want 999", p.Code)
}
if !strings.HasPrefix(p.Message, "upload media failed: ") || !strings.Contains(p.Message, "upload rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaAllTypedFileOpenFailure(t *testing.T) {
runtime, _ := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
FilePath: "missing.bin",
FileName: "missing.bin",
FileSize: 1,
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected typed validation error, got %T (%v)", err, err)
}
}
func TestUploadDriveMediaMultipartTypedBuildsPreparePartsAndFinish(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_typed_1",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_typed_multi"},
},
})
payload := bytes.Repeat([]byte{0xCD}, int(size))
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
}
if fileToken != "file_typed_multi" {
t.Fatalf("fileToken = %q, want %q", fileToken, "file_typed_multi")
}
}
func TestParseDriveMediaMultipartUploadSessionTypedValidatesResponseFields(t *testing.T) {
t.Parallel()
tests := []struct {
name string
data map[string]interface{}
wantText string
}{
{
name: "missing upload id",
data: map[string]interface{}{
"block_size": 4 * 1024 * 1024,
"block_num": 6,
},
wantText: "upload prepare failed: no upload_id returned",
},
{
name: "missing block size",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_num": 6,
},
wantText: "upload prepare failed: invalid block_size returned",
},
{
name: "missing block num",
data: map[string]interface{}{
"upload_id": "upload_123",
"block_size": 4 * 1024 * 1024,
},
wantText: "upload prepare failed: invalid block_num returned",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := parseDriveMediaMultipartUploadSessionTyped(tt.data)
if err == nil || !strings.Contains(err.Error(), tt.wantText) {
t.Fatalf("err = %v, want substring %q", err, tt.wantText)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
})
}
}
func TestUploadDriveMediaMultipartTypedPartAPIFailure(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 999,
"msg": "chunk rejected",
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Category != errs.CategoryAPI || p.Code != 999 {
t.Fatalf("category/code = %s/%d, want api/999", p.Category, p.Code)
}
if !strings.HasPrefix(p.Message, "upload media part failed: ") || !strings.Contains(p.Message, "chunk rejected") {
t.Fatalf("message = %q, want action prefix and server msg", p.Message)
}
}
func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("subtype = %s, want invalid_response", p.Subtype)
}
if !strings.Contains(p.Message, "upload media finish failed: no file_token returned") {
t.Fatalf("message = %q", p.Message)
}
}

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

@@ -1,122 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var DriveCover = common.Shortcut{
Service: "drive",
Command: "+cover",
Description: "List or download stable cover presets for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "spec", Desc: "cover preset: default | icon | grid | small | middle | big | square"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list built-in cover specs without downloading"},
{Name: "output", Desc: "local output path for downloaded cover"},
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDrivePreviewMode(runtime.Str("spec"), runtime.Bool("list-only"), runtime.Str("output"), "spec"); err != nil {
return err
}
if err := validateDrivePreviewIfExists(runtime.Str("if-exists")); err != nil {
return err
}
if spec := strings.TrimSpace(runtime.Str("spec")); spec != "" {
if _, ok := findDriveCoverSpec(spec); !ok {
return wrapDriveCoverUnavailable(spec)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
if runtime.Bool("list-only") {
return common.NewDryRunAPI().
Desc("List built-in cover specs (no API call)").
Set("mode", "list").
Set("file_token", fileToken).
Set("candidates", buildDriveCoverListOutput(fileToken)["candidates"])
}
spec, _ := findDriveCoverSpec(runtime.Str("spec"))
params := buildDriveCoverDownloadParams(strings.TrimSpace(runtime.Str("version")), spec)
dry := common.NewDryRunAPI().
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("Download selected cover preset directly via preview_download").
Params(params).
Set("file_token", fileToken).
Set("selected_spec", spec.Name).
Set("output", runtime.Str("output"))
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedSpec := strings.TrimSpace(runtime.Str("spec"))
outputPath := runtime.Str("output")
ifExists := runtime.Str("if-exists")
if runtime.Bool("list-only") {
runtime.Out(buildDriveCoverListOutput(fileToken), nil)
return nil
}
spec, ok := findDriveCoverSpec(requestedSpec)
if !ok {
return wrapDriveCoverUnavailable(requestedSpec)
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover %s for file %s\n", spec.Name, common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, buildDriveCoverDownloadParams(version, spec), outputPath, ifExists, spec.FallbackExt)
if err != nil {
return wrapDriveCoverDownloadError(err, spec.Name)
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_spec"] = spec.Name
runtime.Out(result, nil)
return nil
},
}
// wrapDriveCoverDownloadError reclassifies preview_download HTTP 404 responses
// on the +cover path as a failed precondition on --spec, because the Drive
// shortcut contract documents 404 as "this file has no artifact for that cover
// preset" rather than a transient transport failure.
func wrapDriveCoverDownloadError(err error, requestedSpec string) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Code != http.StatusNotFound {
return err
}
hint := fmt.Sprintf(
"This may mean no artifact exists for --spec %q, or that the file token/version is invalid. Verify the inputs, or rerun with `lark-cli drive +cover --file-token <file-token> --list-only`. Available cover specs: %s",
requestedSpec,
strings.Join(availableDriveCoverSpecs(), ", "),
)
return errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"preview_download returned HTTP 404 for --spec %q",
requestedSpec,
).WithParam("--spec").WithCode(problem.Code).WithLogID(problem.LogID).WithHint(hint).WithCause(err)
}

View File

@@ -1,118 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var DrivePreview = common.Shortcut{
Service: "drive",
Command: "+preview",
Description: "List or download available preview artifacts for a Drive file",
Risk: "read",
Scopes: []string{"drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "Drive file token", Required: true},
{Name: "type", Desc: "preview type to download: pdf | html | text | image | source"},
{Name: "version", Desc: "optional file version"},
{Name: "list-only", Type: "bool", Desc: "list preview candidates without downloading"},
{Name: "output", Desc: "local output path for downloaded preview"},
{Name: "if-exists", Desc: "output conflict policy: error | overwrite | rename", Default: drivePreviewIfExistsError, Enum: []string{drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validate.ResourceName(runtime.Str("file-token"), "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if err := validateDrivePreviewMode(runtime.Str("type"), runtime.Bool("list-only"), runtime.Str("output"), "type"); err != nil {
return err
}
return validateDrivePreviewIfExists(runtime.Str("if-exists"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
body := map[string]interface{}{}
if version != "" {
body["version"] = version
}
dry := common.NewDryRunAPI().
POST("/open-apis/drive/v1/medias/:file_token/preview_result").
Desc("[1] Fetch preview candidates for a Drive file").
Set("file_token", fileToken)
if len(body) > 0 {
dry.Body(body)
}
if runtime.Bool("list-only") {
return dry.Set("mode", "list")
}
downloadParams := map[string]interface{}{
"preview_type": "<selected type_code from preview_result>",
}
if version != "" {
downloadParams["version"] = version
} else {
downloadParams["version"] = "<resolved version from preview_result>"
}
return dry.
GET("/open-apis/drive/v1/medias/:file_token/preview_download").
Desc("[2] Download the requested preview after selecting a matching candidate from preview_result").
Params(downloadParams).
Set("mode", "download").
Set("requested_type", runtime.Str("type")).
Set("output", runtime.Str("output"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
version := strings.TrimSpace(runtime.Str("version"))
requestedType := strings.TrimSpace(runtime.Str("type"))
outputPath := runtime.Str("output")
ifExists := runtime.Str("if-exists")
body := map[string]interface{}{}
if version != "" {
body["version"] = version
}
fmt.Fprintf(runtime.IO().ErrOut, "Fetching preview candidates: %s\n", common.MaskToken(fileToken))
data, candidates, err := fetchDrivePreviewCandidates(runtime, fileToken, body)
if err != nil {
return err
}
if runtime.Bool("list-only") {
runtime.Out(buildDrivePreviewListOutput(fileToken, candidates), nil)
return nil
}
candidate, ok := selectDrivePreviewCandidate(candidates, requestedType)
if !ok {
return wrapDrivePreviewUnavailable(fileToken, requestedType, candidates, "")
}
if !candidate.Downloadable {
return wrapDrivePreviewNotReady(fileToken, requestedType, candidate)
}
downloadVersion := version
if downloadVersion == "" {
downloadVersion = versionString(data["version"])
}
fmt.Fprintf(runtime.IO().ErrOut, "Downloading preview %s for file %s\n", candidate.Type, common.MaskToken(fileToken))
result, err := downloadDrivePreviewArtifact(ctx, runtime, fileToken, candidate.TypeCode, downloadVersion, outputPath, ifExists, drivePreviewFallbackExt(candidate.Type))
if err != nil {
return err
}
result["mode"] = "download"
result["file_token"] = fileToken
result["selected_type"] = candidate.Type
runtime.Out(result, nil)
return nil
},
}

View File

@@ -1,813 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"errors"
"fmt"
"io/fs"
"mime"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
drivePreviewIfExistsError = "error"
drivePreviewIfExistsOverwrite = "overwrite"
drivePreviewIfExistsRename = "rename"
)
type drivePreviewCandidate struct {
Type string
TypeCode string
TypeName string
Label string
Status string
StatusCode string
Downloadable bool
Reason string
}
type driveCoverSpec struct {
Name string
Label string
Description string
PreviewType string
BusType string
Platform string
Width int
Height int
Policy string
FallbackExt string
}
type driveExtensionResolution struct {
Ext string
Source string
Detail string
}
type drivePreviewTypeMeta struct {
Code string
Name string
Type string
Label string
Aliases []string
}
type drivePreviewStatusMeta struct {
Code string
Name string
Reason string
Downloadable bool
}
var drivePreviewMimeToExt = map[string]string{
"application/json": ".json",
"application/msword": ".doc",
"application/pdf": ".pdf",
"application/xml": ".xml",
"application/zip": ".zip",
"image/bmp": ".bmp",
"image/gif": ".gif",
"image/jpeg": ".jpg",
"image/png": ".png",
"image/svg+xml": ".svg",
"image/webp": ".webp",
"text/csv": ".csv",
"text/html": ".html",
"text/plain": ".txt",
"text/xml": ".xml",
"video/mp4": ".mp4",
"application/octet-stream": "",
}
var drivePreviewTypes = []drivePreviewTypeMeta{
{Code: "0", Name: "PDF", Type: "pdf", Label: "PDF Preview"},
{Code: "1", Name: "PNG", Type: "png", Label: "PNG Preview", Aliases: []string{"image"}},
{Code: "2", Name: "PAGES", Type: "pages", Label: "Paged Preview"},
{Code: "3", Name: "VIDEO", Type: "video", Label: "Video Preview"},
{Code: "4", Name: "MP4_360P", Type: "mp4_360p", Label: "MP4 360P Preview"},
{Code: "5", Name: "MP4_480P", Type: "mp4_480p", Label: "MP4 480P Preview"},
{Code: "6", Name: "MP4_720P", Type: "mp4_720p", Label: "MP4 720P Preview"},
{Code: "7", Name: "JPG", Type: "jpg", Label: "JPG Preview", Aliases: []string{"image"}},
{Code: "8", Name: "HTML", Type: "html", Label: "HTML Preview"},
{Code: "9", Name: "PDF_LIN", Type: "pdf_lin", Label: "Linearized PDF Preview"},
{Code: "10", Name: "XOD", Type: "xod", Label: "XOD Preview"},
{Code: "11", Name: "JPG_LIN", Type: "jpg_lin", Label: "Linearized JPG Preview", Aliases: []string{"image"}},
{Code: "12", Name: "PNG_LIN", Type: "png_lin", Label: "Linearized PNG Preview", Aliases: []string{"image"}},
{Code: "13", Name: "ARCHIVE", Type: "archive", Label: "Archive Preview"},
{Code: "14", Name: "TEXT", Type: "text", Label: "Text Preview"},
{Code: "15", Name: "PDF_PART", Type: "pdf_part", Label: "Partial PDF Preview"},
{Code: "16", Name: "SOURCE_FILE", Type: "source_file", Label: "Source File", Aliases: []string{"source"}},
{Code: "17", Name: "VIDEO_META", Type: "video_meta", Label: "Video Metadata"},
{Code: "18", Name: "WPS", Type: "wps", Label: "WPS Preview"},
{Code: "19", Name: "SPLIT_PNG", Type: "split_png", Label: "Split PNG Preview", Aliases: []string{"image"}},
{Code: "20", Name: "MEDIA_RESULT", Type: "media_result", Label: "Media Result"},
{Code: "21", Name: "MIME", Type: "mime", Label: "MIME Type"},
{Code: "22", Name: "SPILT_IMG_TXT", Type: "spilt_img_txt", Label: "Split Image Text"},
{Code: "23", Name: "MP4_1080P", Type: "mp4_1080p", Label: "MP4 1080P Preview"},
{Code: "24", Name: "IMAGE_META", Type: "image_meta", Label: "Image Metadata"},
{Code: "25", Name: "DOC_PART", Type: "doc_part", Label: "Document Part"},
{Code: "26", Name: "WATERMARK_PDF", Type: "watermark_pdf", Label: "Watermarked PDF Preview"},
{Code: "27", Name: "FILE_WATERMARK", Type: "file_watermark", Label: "File Watermark"},
}
var drivePreviewStatuses = []drivePreviewStatusMeta{
{Code: "0", Name: "READY", Downloadable: true},
{Code: "1", Name: "PROCESSING", Reason: "Preview is still processing."},
{Code: "2", Name: "FAILED", Reason: "Preview generation failed."},
{Code: "3", Name: "FAILED_NOT_RETRY", Reason: "Preview generation failed and will not retry."},
{Code: "4", Name: "INVALID_EXTENTION", Reason: "File extension is invalid for this preview type."},
{Code: "5", Name: "FILE_TOO_LARGE", Reason: "File is too large for preview generation."},
{Code: "6", Name: "EMPTY_FILE", Reason: "File is empty."},
{Code: "7", Name: "NO_SUPPORT", Reason: "Preview is not supported for this file."},
{Code: "8", Name: "INVALID_PREVIEW_TYPE", Reason: "Preview type is invalid."},
{Code: "9", Name: "NEED_PASSWORD", Reason: "Preview requires a password."},
{Code: "10", Name: "FILE_INVALID", Reason: "File is invalid."},
{Code: "11", Name: "TOO_MANY_PAGES", Reason: "File has too many pages for preview."},
{Code: "1001", Name: "ARCHIVE_INVALID_FORMAT", Reason: "Archive format is invalid."},
{Code: "1002", Name: "ARCHIVE_TOO_MANY_NODES", Reason: "Archive contains too many nodes."},
{Code: "1003", Name: "ARCHIVE_TOO_MANY_NODES_PER_DIR", Reason: "Archive directory contains too many nodes."},
{Code: "1004", Name: "THIRD_ENC_NO_PERMISSION", Reason: "No permission for third-party encrypted file."},
{Code: "1006", Name: "NOT_SUPPORT_DECRYPT_THIRD_ENC_FILE", Reason: "Third-party encrypted file cannot be decrypted for preview."},
}
var drivePreviewTypeByCode = func() map[string]drivePreviewTypeMeta {
out := make(map[string]drivePreviewTypeMeta, len(drivePreviewTypes))
for _, meta := range drivePreviewTypes {
out[meta.Code] = meta
}
return out
}()
var drivePreviewStatusByCode = func() map[string]drivePreviewStatusMeta {
out := make(map[string]drivePreviewStatusMeta, len(drivePreviewStatuses))
for _, meta := range drivePreviewStatuses {
out[meta.Code] = meta
}
return out
}()
var driveCoverSpecs = []driveCoverSpec{
{
Name: "default",
Label: "Default Cover",
Description: "Standard large cover (1280x1280).",
PreviewType: "1",
BusType: "cover",
Platform: "pc",
FallbackExt: ".png",
},
{
Name: "icon",
Label: "Icon",
Description: "Small list icon (120x120).",
PreviewType: "1",
BusType: "icon",
FallbackExt: ".png",
},
{
Name: "grid",
Label: "Grid Cover",
Description: "Grid/card stream cover (360x360).",
PreviewType: "1",
BusType: "grid",
FallbackExt: ".png",
},
{
Name: "small",
Label: "Small Graph",
Description: "PC small graph cover (480x480).",
PreviewType: "1",
BusType: "small_graph",
Platform: "pc",
FallbackExt: ".png",
},
{
Name: "middle",
Label: "Middle Cover",
Description: "Medium-sized cover (720x720).",
PreviewType: "1",
BusType: "middle",
FallbackExt: ".png",
},
{
Name: "big",
Label: "Big Cover",
Description: "Large mobile-oriented cover (850x850).",
PreviewType: "1",
BusType: "big",
Platform: "mobile",
FallbackExt: ".png",
},
{
Name: "square",
Label: "Square Cover",
Description: "Square-cropped grid cover (360x360).",
PreviewType: "1",
Width: 360,
Height: 360,
Policy: "near",
FallbackExt: ".png",
},
}
// validateDrivePreviewMode checks the required flag combinations for list and
// download modes.
func validateDrivePreviewMode(selected string, listOnly bool, outputPath, flagName string) error {
selected = strings.TrimSpace(selected)
outputPath = strings.TrimSpace(outputPath)
selectedFlag := "--" + flagName
if listOnly {
if selected != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be combined with --list-only", selectedFlag).WithParam(selectedFlag)
}
if outputPath != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be combined with --list-only").WithParam("--output")
}
return nil
}
if selected == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "either --list-only or %s is required", selectedFlag).WithParam(selectedFlag)
}
if outputPath == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output is required when %s is set", selectedFlag).WithParam("--output")
}
return nil
}
// validateDrivePreviewIfExists validates the accepted overwrite policy values.
func validateDrivePreviewIfExists(policy string) error {
switch strings.TrimSpace(policy) {
case "", drivePreviewIfExistsError, drivePreviewIfExistsOverwrite, drivePreviewIfExistsRename:
return nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", policy).WithParam("--if-exists")
}
}
// fetchDrivePreviewCandidates loads preview_result data and normalizes the
// returned candidate list.
func fetchDrivePreviewCandidates(runtime *common.RuntimeContext, fileToken string, body map[string]interface{}) (map[string]interface{}, []drivePreviewCandidate, error) {
data, err := runtime.CallAPITyped(
"POST",
fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_result", validate.EncodePathSegment(fileToken)),
nil,
body,
)
if err != nil {
return nil, nil, err
}
return data, normalizeDrivePreviewCandidates(data), nil
}
// normalizeDrivePreviewCandidates converts preview_result items into internal
// candidate records with stable type and status metadata.
func normalizeDrivePreviewCandidates(data map[string]interface{}) []drivePreviewCandidate {
items := common.GetSlice(data, "preview_results")
candidates := make([]drivePreviewCandidate, 0, len(items))
for _, item := range items {
raw, ok := item.(map[string]interface{})
if !ok {
continue
}
typeCode := firstString(raw, "preview_type", "type_code", "type")
statusCode := firstString(raw, "preview_status", "status_code", "status")
candidate := drivePreviewCandidate{
TypeCode: typeCode,
StatusCode: statusCode,
Reason: strings.TrimSpace(firstString(raw, "reason", "status_msg", "message", "msg", "detail")),
}
applyDrivePreviewTypeMeta(&candidate)
applyDrivePreviewStatusMeta(&candidate)
candidates = append(candidates, candidate)
}
return candidates
}
// selectDrivePreviewCandidate matches a requested preview type or alias against
// the available candidates.
func selectDrivePreviewCandidate(candidates []drivePreviewCandidate, requested string) (drivePreviewCandidate, bool) {
requested = normalizeDrivePreviewRequest(requested)
if requested == "" {
return drivePreviewCandidate{}, false
}
for _, candidate := range candidates {
if requested == candidate.Type || requested == strings.ToLower(candidate.TypeName) || requested == strings.ToLower(strings.TrimSpace(candidate.TypeCode)) {
return candidate, true
}
}
var firstAliasMatch drivePreviewCandidate
hasAliasMatch := false
for _, candidate := range candidates {
if !slices.Contains(previewAliasesForCandidate(candidate), requested) {
continue
}
if candidate.Downloadable {
return candidate, true
}
if !hasAliasMatch {
firstAliasMatch = candidate
hasAliasMatch = true
}
}
if hasAliasMatch {
return firstAliasMatch, true
}
return drivePreviewCandidate{}, false
}
// buildDrivePreviewListOutput formats preview candidates for --list-only
// responses.
func buildDrivePreviewListOutput(fileToken string, candidates []drivePreviewCandidate) map[string]interface{} {
items := make([]map[string]interface{}, 0, len(candidates))
for _, candidate := range candidates {
item := map[string]interface{}{
"type": candidate.Type,
"type_code": candidate.TypeCode,
"label": candidate.Label,
"status": candidate.Status,
"status_code": candidate.StatusCode,
"downloadable": candidate.Downloadable,
}
if candidate.Reason != "" {
item["reason"] = candidate.Reason
}
items = append(items, item)
}
out := map[string]interface{}{
"mode": "list",
"file_token": fileToken,
"candidates": items,
}
if len(items) > 0 {
out["next_action"] = "select one candidate and rerun with --type plus --output"
}
return out
}
// buildDriveCoverListOutput formats the built-in cover specs for --list-only
// responses.
func buildDriveCoverListOutput(fileToken string) map[string]interface{} {
items := make([]map[string]interface{}, 0, len(driveCoverSpecs))
for _, spec := range driveCoverSpecs {
item := map[string]interface{}{
"spec": spec.Name,
"label": spec.Label,
}
if spec.Description != "" {
item["description"] = spec.Description
}
items = append(items, item)
}
return map[string]interface{}{
"mode": "list",
"file_token": fileToken,
"candidates": items,
"next_action": "select one spec and rerun with --spec plus --output",
}
}
// findDriveCoverSpec resolves a cover spec by its user-facing name.
func findDriveCoverSpec(name string) (driveCoverSpec, bool) {
name = strings.ToLower(strings.TrimSpace(name))
for _, spec := range driveCoverSpecs {
if spec.Name == name {
return spec, true
}
}
return driveCoverSpec{}, false
}
// buildDriveCoverDownloadParams translates a cover spec into preview_download
// query parameters.
func buildDriveCoverDownloadParams(version string, spec driveCoverSpec) map[string]interface{} {
params := map[string]interface{}{
"preview_type": spec.PreviewType,
}
if strings.TrimSpace(spec.BusType) != "" {
params["bus_type"] = spec.BusType
}
if strings.TrimSpace(spec.Platform) != "" {
params["platform"] = spec.Platform
}
if spec.Width > 0 {
params["width"] = spec.Width
}
if spec.Height > 0 {
params["height"] = spec.Height
}
if strings.TrimSpace(spec.Policy) != "" {
params["policy"] = spec.Policy
}
if strings.TrimSpace(version) != "" {
params["version"] = version
}
return params
}
// downloadDrivePreviewArtifact downloads a preview artifact for a single
// preview_type value.
func downloadDrivePreviewArtifact(ctx context.Context, runtime *common.RuntimeContext, fileToken, previewType, version, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
query := map[string]interface{}{
"preview_type": previewType,
}
if strings.TrimSpace(version) != "" {
query["version"] = version
}
return downloadDrivePreviewArtifactWithParams(ctx, runtime, fileToken, query, outputPath, ifExists, fallbackExt)
}
// downloadDrivePreviewArtifactWithParams downloads a preview artifact using the
// provided preview_download query parameters and writes it to the local path.
func downloadDrivePreviewArtifactWithParams(ctx context.Context, runtime *common.RuntimeContext, fileToken string, query map[string]interface{}, outputPath, ifExists, fallbackExt string) (map[string]interface{}, error) {
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
queryParams := make(larkcore.QueryParams, len(query))
for key, value := range query {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" {
continue
}
queryParams[key] = []string{text}
}
apiReq := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/preview_download", validate.EncodePathSegment(fileToken)),
QueryParams: queryParams,
}
resp, err := runtime.DoAPIStream(ctx, apiReq)
if err != nil {
return nil, wrapDriveNetworkErr(err, "preview download failed: %s", err)
}
defer resp.Body.Close()
finalPath, _, err := resolveDrivePreviewOutputPath(runtime, outputPath, resp.Header, fallbackExt, ifExists)
if err != nil {
return nil, err
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, driveSaveError(err)
}
savedPath, _ := runtime.ResolveSavePath(finalPath)
if savedPath == "" {
savedPath = finalPath
}
return map[string]interface{}{
"output_path": savedPath,
"size_bytes": result.Size(),
"content_type": resp.Header.Get("Content-Type"),
"status": "READY",
}, nil
}
// resolveDrivePreviewOutputPath finalizes the save path, applying extension
// inference and the selected collision policy.
func resolveDrivePreviewOutputPath(runtime *common.RuntimeContext, outputPath string, header http.Header, fallbackExt, ifExists string) (string, *driveExtensionResolution, error) {
finalPath, resolution := autoAppendDrivePreviewExtension(outputPath, header, fallbackExt)
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
switch ifExists {
case "", drivePreviewIfExistsError:
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "output file already exists: %s (use --if-exists overwrite or rename)", finalPath).WithParam("--output")
} else if !errors.Is(statErr, fs.ErrNotExist) {
return "", nil, errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", finalPath, statErr).WithCause(statErr)
}
return finalPath, resolution, nil
case drivePreviewIfExistsOverwrite:
return finalPath, resolution, nil
case drivePreviewIfExistsRename:
renamed, err := nextAvailableDrivePreviewPath(runtime.FileIO(), finalPath)
if err != nil {
return "", nil, err
}
if _, err := runtime.ResolveSavePath(renamed); err != nil {
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output")
}
return renamed, resolution, nil
default:
return "", nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --if-exists %q: allowed values are error, overwrite, rename", ifExists).WithParam("--if-exists")
}
}
// nextAvailableDrivePreviewPath finds the first unused "name (n)" variant for a
// target output path.
func nextAvailableDrivePreviewPath(fio fileio.FileIO, path string) (string, error) {
if _, err := fio.Stat(path); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return path, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access output path %s: %s", path, err).WithCause(err)
}
dir := filepath.Dir(path)
ext := filepath.Ext(path)
base := strings.TrimSuffix(filepath.Base(path), ext)
for i := 1; i < 10000; i++ {
candidate := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", base, i, ext))
if _, err := fio.Stat(candidate); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return candidate, nil
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot access candidate output path %s: %s", candidate, err).WithCause(err)
}
}
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot allocate a unique output path for %s", path)
}
// autoAppendDrivePreviewExtension appends an inferred extension when the user
// did not provide one explicitly.
func autoAppendDrivePreviewExtension(outputPath string, header http.Header, fallbackExt string) (string, *driveExtensionResolution) {
if drivePreviewHasExplicitExtension(outputPath) {
return outputPath, nil
}
normalizedPath := outputPath
if filepath.Ext(outputPath) == "." {
normalizedPath = strings.TrimSuffix(outputPath, ".")
}
if resolution := drivePreviewExtensionByContentType(header.Get("Content-Type")); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
return normalizedPath + resolution.Ext, resolution
}
if fallbackExt != "" {
return normalizedPath + fallbackExt, &driveExtensionResolution{
Ext: fallbackExt,
Source: "fallback",
Detail: "default fallback",
}
}
return outputPath, nil
}
// drivePreviewHasExplicitExtension reports whether the path already ends with a
// usable filename extension.
func drivePreviewHasExplicitExtension(path string) bool {
ext := filepath.Ext(path)
return ext != "" && ext != "."
}
// drivePreviewExtensionByContentType maps a response Content-Type header to a
// file extension when possible.
func drivePreviewExtensionByContentType(contentType string) *driveExtensionResolution {
if contentType == "" {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
}
if ext, ok := drivePreviewMimeToExt[strings.ToLower(mediaType)]; ok && ext != "" {
return &driveExtensionResolution{
Ext: ext,
Source: "Content-Type",
Detail: contentType,
}
}
return nil
}
// drivePreviewExtensionByContentDisposition extracts an extension from the
// response filename metadata.
func drivePreviewExtensionByContentDisposition(header http.Header) *driveExtensionResolution {
filename := strings.TrimSpace(larkcore.FileNameByHeader(header))
if filename == "" {
return nil
}
ext := filepath.Ext(filename)
if ext == "" || ext == "." {
return nil
}
return &driveExtensionResolution{
Ext: ext,
Source: "Content-Disposition",
Detail: filename,
}
}
// drivePreviewFallbackExt returns the default extension for known preview type
// aliases when headers do not provide one.
func drivePreviewFallbackExt(alias string) string {
switch normalizeDrivePreviewRequest(alias) {
case "pdf":
return ".pdf"
case "html":
return ".html"
case "text":
return ".txt"
case "png", "png_lin", "split_png":
return ".png"
case "jpg", "jpg_lin":
return ".jpg"
case "source", "source_file":
return ""
default:
return ""
}
}
// applyDrivePreviewTypeMeta fills normalized type metadata from the preview
// type code.
func applyDrivePreviewTypeMeta(candidate *drivePreviewCandidate) {
if candidate == nil {
return
}
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
candidate.Type = meta.Type
candidate.TypeName = meta.Name
candidate.Label = meta.Label
return
}
code := strings.TrimSpace(candidate.TypeCode)
if code == "" {
candidate.Type = "unknown"
candidate.TypeName = "UNKNOWN"
candidate.Label = "Unknown Preview Type"
return
}
candidate.Type = "unknown_" + code
candidate.TypeName = "UNKNOWN"
candidate.Label = fmt.Sprintf("Unknown Preview Type %s", code)
}
// applyDrivePreviewStatusMeta fills normalized status metadata from the preview
// status code.
func applyDrivePreviewStatusMeta(candidate *drivePreviewCandidate) {
if candidate == nil {
return
}
if meta, ok := drivePreviewStatusByCode[candidate.StatusCode]; ok {
candidate.Status = meta.Name
candidate.Downloadable = meta.Downloadable
if candidate.Reason == "" && !meta.Downloadable {
candidate.Reason = meta.Reason
}
if meta.Downloadable {
candidate.Reason = ""
}
return
}
candidate.Status = "UNKNOWN"
candidate.Downloadable = false
if candidate.Reason == "" {
if strings.TrimSpace(candidate.StatusCode) == "" {
candidate.Reason = "Preview status is missing."
} else {
candidate.Reason = fmt.Sprintf("Unknown preview status %s.", candidate.StatusCode)
}
}
}
// normalizeDrivePreviewRequest canonicalizes user input for preview type
// matching.
func normalizeDrivePreviewRequest(requested string) string {
requested = strings.ToLower(strings.TrimSpace(requested))
requested = strings.ReplaceAll(requested, "-", "_")
requested = strings.ReplaceAll(requested, " ", "_")
return requested
}
// previewAliasesForCandidate returns configured aliases for a preview
// candidate's type code.
func previewAliasesForCandidate(candidate drivePreviewCandidate) []string {
if meta, ok := drivePreviewTypeByCode[candidate.TypeCode]; ok {
return meta.Aliases
}
return nil
}
// firstString returns the first non-empty string-like value from the provided
// keys.
func firstString(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
v, ok := m[key]
if !ok || v == nil {
continue
}
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
return t
}
case fmt.Stringer:
if s := strings.TrimSpace(t.String()); s != "" {
return s
}
case float64:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case bool:
return strconv.FormatBool(t)
}
}
return ""
}
// versionString normalizes version fields from heterogeneous API payload types.
func versionString(v interface{}) string {
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
case float64:
return strconv.FormatInt(int64(t), 10)
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
default:
return ""
}
}
// availableDrivePreviewTypes lists unique normalized preview type names from
// the candidate set.
func availableDrivePreviewTypes(candidates []drivePreviewCandidate) []string {
seen := map[string]bool{}
out := make([]string, 0, len(candidates))
for _, candidate := range candidates {
name := strings.TrimSpace(candidate.Type)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
return out
}
// availableDriveCoverSpecs lists the supported built-in cover spec names.
func availableDriveCoverSpecs() []string {
out := make([]string, 0, len(driveCoverSpecs))
for _, spec := range driveCoverSpecs {
out = append(out, spec.Name)
}
return out
}
// wrapDrivePreviewUnavailable builds a validation error for an unsupported
// preview selection.
func wrapDrivePreviewUnavailable(fileToken, requested string, candidates []drivePreviewCandidate, reason string) error {
available := availableDrivePreviewTypes(candidates)
if reason == "" {
reason = fmt.Sprintf("requested preview type %q is not available for file %s", requested, fileToken)
}
hint := "rerun with --list-only to inspect available preview types"
if len(available) > 0 {
hint = fmt.Sprintf("available preview types: %s", strings.Join(available, ", "))
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// wrapDrivePreviewNotReady builds an actionable error for a preview candidate
// that exists but is not yet downloadable.
func wrapDrivePreviewNotReady(fileToken, requested string, candidate drivePreviewCandidate) error {
reason := candidate.Reason
if reason == "" {
reason = fmt.Sprintf("preview type %q is not downloadable yet (status=%s)", requested, candidate.Status)
}
hint := fmt.Sprintf("rerun `lark-cli drive +preview --file-token %s --list-only` to inspect current candidate status", fileToken)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, reason).WithHint(hint).WithParam("--type")
}
// wrapDriveCoverUnavailable builds a validation error for an unknown cover
// spec.
func wrapDriveCoverUnavailable(requested string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --spec %q", requested).
WithHint("available cover specs: %s", strings.Join(availableDriveCoverSpecs(), ", ")).
WithParam("--spec")
}

View File

@@ -1,926 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestDrivePreviewListOnlyNormalizesCandidates verifies list mode output is
// normalized from preview_result payloads.
func TestDrivePreviewListOnlyNormalizesCandidates(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 0, "preview_status": 0},
{"preview_type": 14, "preview_status": 1},
{"preview_type": 16, "preview_status": 7},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--list-only",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["mode"]; got != "list" {
t.Fatalf("mode=%v, want list", got)
}
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != 3 {
t.Fatalf("len(candidates)=%d, want 3", len(candidates))
}
first, _ := candidates[0].(map[string]interface{})
if got := first["type"]; got != "pdf" {
t.Fatalf("candidate[0].type=%v, want pdf", got)
}
if got := first["type_code"]; got != "0" {
t.Fatalf("candidate[0].type_code=%v, want 0", got)
}
if got := first["status"]; got != "READY" {
t.Fatalf("candidate[0].status=%v, want READY", got)
}
if got := first["downloadable"]; got != true {
t.Fatalf("candidate[0].downloadable=%v, want true", got)
}
second, _ := candidates[1].(map[string]interface{})
if got := second["status_code"]; got != "1" {
t.Fatalf("candidate[1].status_code=%v, want 1", got)
}
if got := second["reason"]; got != "Preview is still processing." {
t.Fatalf("candidate[1].reason=%v, want processing reason", got)
}
}
// TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy verifies preview
// downloads use the resolved type and rename collision handling.
func TestDrivePreviewDownloadUsesResolvedTypeCodeAndRenamePolicy(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": 7,
"preview_results": []map[string]interface{}{
{"preview_type": 0, "preview_status": 0},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_preview/preview_download?preview_type=0",
Status: 200,
Body: []byte("%PDF-1.7"),
Headers: http.Header{
"Content-Type": []string{"application/pdf"},
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "report.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--output", "report",
"--if-exists", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["selected_type"]; got != "pdf" {
t.Fatalf("selected_type=%v, want pdf", got)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "report (1).pdf")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected preview artifact at %q: %v", wantPath, err)
}
}
// TestDrivePreviewRejectsUnavailableType verifies unavailable preview types
// return an actionable validation error.
func TestDrivePreviewRejectsUnavailableType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 8, "preview_status": 0},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--output", "report",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected unavailable type error, got nil")
}
if !strings.Contains(err.Error(), `requested preview type "pdf" is not available`) {
t.Fatalf("unexpected error: %v", err)
}
}
// TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch verifies alias
// selection prefers a downloadable candidate over an earlier unavailable one.
func TestSelectDrivePreviewCandidatePrefersDownloadableAliasMatch(t *testing.T) {
candidate, ok := selectDrivePreviewCandidate([]drivePreviewCandidate{
{Type: "png", TypeCode: "1", Downloadable: false, Status: "PROCESSING"},
{Type: "jpg", TypeCode: "7", Downloadable: true, Status: "READY"},
}, "image")
if !ok {
t.Fatal("expected alias match, got none")
}
if candidate.Type != "jpg" {
t.Fatalf("selected candidate=%q, want jpg", candidate.Type)
}
if !candidate.Downloadable {
t.Fatalf("selected candidate should be downloadable: %+v", candidate)
}
}
// TestDriveCoverListOnlyUsesStaticSpecs verifies cover list mode returns the
// built-in spec catalog without calling APIs.
func TestDriveCoverListOnlyUsesStaticSpecs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--list-only",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
candidates, _ := data["candidates"].([]interface{})
if len(candidates) != len(driveCoverSpecs) {
t.Fatalf("len(candidates)=%d, want %d", len(candidates), len(driveCoverSpecs))
}
last, _ := candidates[len(candidates)-1].(map[string]interface{})
if got := last["spec"]; got != "square" {
t.Fatalf("last spec=%v, want square", got)
}
}
// TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType verifies cover
// downloads send the expected preview_download query mapping.
func TestDriveCoverDownloadUsesMappedCoverOptionAndPreviewType(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
var capturedQuery url.Values
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
Status: 200,
Body: []byte("png-data"),
Headers: http.Header{
"Content-Type": []string{"image/png"},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.Query()
},
})
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "square",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if got := data["selected_spec"]; got != "square" {
t.Fatalf("selected_spec=%v, want square", got)
}
resolvedTmpDir, err := filepath.EvalSymlinks(tmpDir)
if err != nil {
t.Fatalf("EvalSymlinks() error: %v", err)
}
wantPath := filepath.Join(resolvedTmpDir, "cover.png")
if got := data["output_path"]; got != wantPath {
t.Fatalf("output_path=%v, want %s", got, wantPath)
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("expected cover file at %q: %v", wantPath, err)
}
if got := capturedQuery.Get("preview_type"); got != "1" {
t.Fatalf("preview_type=%q, want 1", got)
}
if got := capturedQuery.Get("bus_type"); got != "" {
t.Fatalf("bus_type=%q, want empty for square crop flow", got)
}
if got := capturedQuery.Get("platform"); got != "" {
t.Fatalf("platform=%q, want empty when using default platform", got)
}
if got := capturedQuery.Get("width"); got != "360" {
t.Fatalf("width=%q, want 360", got)
}
if got := capturedQuery.Get("height"); got != "360" {
t.Fatalf("height=%q, want 360", got)
}
if got := capturedQuery.Get("policy"); got != "near" {
t.Fatalf("policy=%q, want near", got)
}
}
// TestDriveCoverDownload404ReturnsFailedPrecondition verifies the +cover path
// reclassifies preview_download HTTP 404 as a non-retryable spec/state issue.
func TestDriveCoverDownload404ReturnsFailedPrecondition(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/medias/file_cover/preview_download",
Status: http.StatusNotFound,
Body: []byte(`{"code":404,"msg":"no artifact"}`),
Headers: http.Header{
"Content-Type": []string{"application/json"},
},
})
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "square",
"--output", "cover",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected cover 404 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.SubtypeFailedPrecondition {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if validationErr.Param != "--spec" {
t.Fatalf("param=%q, want --spec", validationErr.Param)
}
if validationErr.Code != http.StatusNotFound {
t.Fatalf("code=%d, want %d", validationErr.Code, http.StatusNotFound)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want --list-only guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "file token/version is invalid") {
t.Fatalf("hint=%q, want invalid file token/version guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Hint, "available cover specs") && !strings.Contains(validationErr.Hint, "default, icon, grid") {
t.Fatalf("hint=%q, want available cover specs guidance", validationErr.Hint)
}
if !strings.Contains(validationErr.Error(), `preview_download returned HTTP 404 for --spec "square"`) {
t.Fatalf("message=%q, want neutral 404 message", validationErr.Error())
}
}
// newDrivePreviewRuntime builds a shortcut runtime with preconfigured preview
// and cover flags for DryRun and helper tests.
func newDrivePreviewRuntime(t *testing.T, use string, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: use}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("spec", "", "")
cmd.Flags().String("version", "", "")
cmd.Flags().String("output", "", "")
cmd.Flags().String("if-exists", drivePreviewIfExistsError, "")
cmd.Flags().Bool("list-only", false, "")
for name, value := range stringFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
for name, value := range boolFlags {
if !value {
continue
}
if err := cmd.Flags().Set(name, "true"); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return common.TestNewRuntimeContextWithCtx(context.Background(), cmd, driveTestConfig())
}
// decodeDryRunOutput marshals a DryRunAPI helper into a generic map for test
// assertions.
func decodeDryRunOutput(t *testing.T, dry *common.DryRunAPI) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return out
}
// TestDrivePreviewDryRunIncludesVersionAndMode verifies preview DryRun records
// versioned request metadata in download mode.
func TestDrivePreviewDryRunIncludesVersionAndMode(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
"type": "image",
"version": "7",
"output": "preview",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "download" {
t.Fatalf("mode=%v, want download", got)
}
if got := data["requested_type"]; got != "image" {
t.Fatalf("requested_type=%v, want image", got)
}
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
call, _ := api[0].(map[string]interface{})
if got := call["method"]; got != "POST" {
t.Fatalf("method=%v, want POST", got)
}
if got := call["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_result" {
t.Fatalf("url=%v, want preview_result", got)
}
body, _ := call["body"].(map[string]interface{})
if got := body["version"]; got != "7" {
t.Fatalf("body.version=%v, want 7", got)
}
downloadCall, _ := api[1].(map[string]interface{})
if got := downloadCall["method"]; got != "GET" {
t.Fatalf("download method=%v, want GET", got)
}
if got := downloadCall["url"]; got != "/open-apis/drive/v1/medias/file_preview/preview_download" {
t.Fatalf("download url=%v, want preview_download", got)
}
params, _ := downloadCall["params"].(map[string]interface{})
if got := params["preview_type"]; got != "<selected type_code from preview_result>" {
t.Fatalf("download params.preview_type=%v, want placeholder", got)
}
if got := params["version"]; got != "7" {
t.Fatalf("download params.version=%v, want 7", got)
}
}
// TestDrivePreviewDryRunListOmitsBodyWithoutVersion verifies list-mode DryRun
// omits the request body when no version is supplied.
func TestDrivePreviewDryRunListOmitsBodyWithoutVersion(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
}, map[string]bool{"list-only": true})
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
if got := data["mode"]; got != "list" {
t.Fatalf("mode=%v, want list", got)
}
api, _ := data["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
if _, ok := call["body"]; ok {
t.Fatalf("dry-run body should be omitted when version is empty: %#v", call)
}
}
// TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion verifies
// download-mode DryRun documents the second preview_download step even when the
// final version is only known after preview_result resolves candidates.
func TestDrivePreviewDryRunDownloadWithoutVersionShowsResolvedVersion(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +preview", map[string]string{
"file-token": "file_preview",
"type": "pdf",
"output": "preview",
}, nil)
data := decodeDryRunOutput(t, DrivePreview.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
if len(api) != 2 {
t.Fatalf("len(api)=%d, want 2", len(api))
}
downloadCall, _ := api[1].(map[string]interface{})
params, _ := downloadCall["params"].(map[string]interface{})
if got := params["version"]; got != "<resolved version from preview_result>" {
t.Fatalf("download params.version=%v, want resolved-version placeholder", got)
}
}
// TestDriveCoverDryRunListAndDownload verifies cover DryRun output for both
// list and download modes.
func TestDriveCoverDryRunListAndDownload(t *testing.T) {
listRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
}, map[string]bool{"list-only": true})
listData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), listRuntime))
if got := listData["mode"]; got != "list" {
t.Fatalf("list mode=%v, want list", got)
}
if _, ok := listData["candidates"].([]interface{}); !ok {
t.Fatalf("list candidates missing: %#v", listData)
}
downloadRuntime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
"spec": "square",
"version": "3",
"output": "cover",
}, nil)
downloadData := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), downloadRuntime))
if got := downloadData["selected_spec"]; got != "square" {
t.Fatalf("selected_spec=%v, want square", got)
}
api, _ := downloadData["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
params, _ := call["params"].(map[string]interface{})
if got := params["width"]; got != float64(360) {
t.Fatalf("params.width=%v, want 360", got)
}
if got := params["policy"]; got != "near" {
t.Fatalf("params.policy=%v, want near", got)
}
}
// TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform verifies DryRun
// params include version and built-in platform metadata for default covers.
func TestDriveCoverDryRunDefaultSpecIncludesVersionAndPlatform(t *testing.T) {
runtime := newDrivePreviewRuntime(t, "drive +cover", map[string]string{
"file-token": "file_cover",
"spec": "default",
"version": "5",
"output": "cover",
}, nil)
data := decodeDryRunOutput(t, DriveCover.DryRun(context.Background(), runtime))
api, _ := data["api"].([]interface{})
call, _ := api[0].(map[string]interface{})
params, _ := call["params"].(map[string]interface{})
if got := params["bus_type"]; got != "cover" {
t.Fatalf("params.bus_type=%v, want cover", got)
}
if got := params["platform"]; got != "pc" {
t.Fatalf("params.platform=%v, want pc", got)
}
if got := params["version"]; got != "5" {
t.Fatalf("params.version=%v, want 5", got)
}
}
// TestDrivePreviewValidationErrors verifies preview flag validation rejects
// incomplete and conflicting argument combinations.
func TestDrivePreviewValidationErrors(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "either --list-only or --type is required") {
t.Fatalf("unexpected missing type error: %v", err)
}
err = mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--list-only",
"--type", "pdf",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--type cannot be combined with --list-only") {
t.Fatalf("unexpected list-only conflict: %v", err)
}
err = mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "pdf",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--output is required when --type is set") {
t.Fatalf("unexpected missing output error: %v", err)
}
}
// TestDrivePreviewNotReadyReturnsFailedPrecondition verifies a known but
// unready preview candidate returns a failed-precondition error.
func TestDrivePreviewNotReadyReturnsFailedPrecondition(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/file_preview/preview_result",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"preview_results": []map[string]interface{}{
{"preview_type": 1, "preview_status": 1},
},
},
},
})
err := mountAndRunDrive(t, DrivePreview, []string{
"+preview",
"--file-token", "file_preview",
"--type", "image",
"--output", "preview",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected not-ready 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.SubtypeFailedPrecondition {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeFailedPrecondition)
}
if validationErr.Param != "--type" {
t.Fatalf("param=%q, want --type", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
}
}
// TestDriveCoverRejectsUnknownSpec verifies unsupported cover specs produce a
// validation error with available alternatives.
func TestDriveCoverRejectsUnknownSpec(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "poster",
"--output", "cover",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected invalid spec error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--spec" {
t.Fatalf("param=%q, want --spec", validationErr.Param)
}
if !strings.Contains(validationErr.Hint, "available cover specs") {
t.Fatalf("hint=%q, want available specs", validationErr.Hint)
}
}
// TestDriveCoverValidationErrors verifies cover flag validation rejects
// incomplete and conflicting argument combinations.
func TestDriveCoverValidationErrors(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--spec", "default",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--output is required when --spec is set") {
t.Fatalf("unexpected missing output error: %v", err)
}
err = mountAndRunDrive(t, DriveCover, []string{
"+cover",
"--file-token", "file_cover",
"--list-only",
"--spec", "default",
"--as", "bot",
}, f, nil)
if err == nil || !strings.Contains(err.Error(), "--spec cannot be combined with --list-only") {
t.Fatalf("unexpected list-only conflict: %v", err)
}
}
// TestDrivePreviewCommonHelpers exercises helper branches for extension
// inference and fallback extension mapping.
func TestDrivePreviewCommonHelpers(t *testing.T) {
if got := drivePreviewFallbackExt("pdf"); got != ".pdf" {
t.Fatalf("fallbackExt(pdf)=%q, want .pdf", got)
}
if got := drivePreviewFallbackExt("html"); got != ".html" {
t.Fatalf("fallbackExt(html)=%q, want .html", got)
}
if got := drivePreviewFallbackExt("text"); got != ".txt" {
t.Fatalf("fallbackExt(text)=%q, want .txt", got)
}
if got := drivePreviewFallbackExt("jpg"); got != ".jpg" {
t.Fatalf("fallbackExt(jpg)=%q, want .jpg", got)
}
if got := drivePreviewFallbackExt("jpg_lin"); got != ".jpg" {
t.Fatalf("fallbackExt(jpg_lin)=%q, want .jpg", got)
}
if got := drivePreviewFallbackExt("split_png"); got != ".png" {
t.Fatalf("fallbackExt(split_png)=%q, want .png", got)
}
if got := drivePreviewFallbackExt("source"); got != "" {
t.Fatalf("fallbackExt(source)=%q, want empty", got)
}
if got := drivePreviewFallbackExt("unknown"); got != "" {
t.Fatalf("fallbackExt(unknown)=%q, want empty", got)
}
specs := availableDriveCoverSpecs()
if len(specs) == 0 || specs[len(specs)-1] != "square" {
t.Fatalf("availableDriveCoverSpecs()=%v, want square included", specs)
}
header := http.Header{}
header.Set("Content-Disposition", `attachment; filename="preview.pdf"`)
resolution := drivePreviewExtensionByContentDisposition(header)
if resolution == nil || resolution.Ext != ".pdf" {
t.Fatalf("content disposition resolution=%+v, want .pdf", resolution)
}
header.Set("Content-Disposition", `attachment; filename="preview"`)
if resolution := drivePreviewExtensionByContentDisposition(header); resolution != nil {
t.Fatalf("content disposition without ext should be nil: %+v", resolution)
}
path, fallback := autoAppendDrivePreviewExtension("cover", http.Header{}, ".png")
if path != "cover.png" || fallback == nil || fallback.Source != "fallback" {
t.Fatalf("fallback append = (%q, %+v), want cover.png with fallback source", path, fallback)
}
path, fallback = autoAppendDrivePreviewExtension("cover.", http.Header{}, ".png")
if path != "cover.png" || fallback == nil {
t.Fatalf("trailing-dot append = (%q, %+v), want cover.png", path, fallback)
}
path, fallback = autoAppendDrivePreviewExtension("cover.pdf", http.Header{}, ".png")
if path != "cover.pdf" || fallback != nil {
t.Fatalf("explicit ext append = (%q, %+v), want unchanged path", path, fallback)
}
}
// TestDrivePreviewMetadataAndPathResolution verifies metadata normalization
// and output path resolution helpers across rename and overwrite flows.
func TestDrivePreviewMetadataAndPathResolution(t *testing.T) {
candidate := drivePreviewCandidate{TypeCode: "999", StatusCode: "", Reason: ""}
applyDrivePreviewTypeMeta(&candidate)
applyDrivePreviewStatusMeta(&candidate)
if candidate.Type != "unknown_999" {
t.Fatalf("candidate.Type=%q, want unknown_999", candidate.Type)
}
if candidate.Reason != "Preview status is missing." {
t.Fatalf("candidate.Reason=%q, want missing-status reason", candidate.Reason)
}
ready := drivePreviewCandidate{TypeCode: "1", StatusCode: "0"}
applyDrivePreviewTypeMeta(&ready)
applyDrivePreviewStatusMeta(&ready)
if ready.Type != "png" || !ready.Downloadable {
t.Fatalf("ready candidate=%+v, want downloadable png", ready)
}
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "preview.pdf"), []byte("old"), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
runtime := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
header := http.Header{}
header.Set("Content-Type", "application/pdf")
renamed, _, err := resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", drivePreviewIfExistsRename)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(rename) error: %v", err)
}
if !strings.HasSuffix(renamed, "preview (1).pdf") {
t.Fatalf("renamed=%q, want preview (1).pdf suffix", renamed)
}
_, _, err = resolveDrivePreviewOutputPath(runtime, "preview", header, ".pdf", "keep")
if err == nil {
t.Fatal("expected invalid if-exists error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != "--if-exists" {
t.Fatalf("param=%q, want --if-exists", validationErr.Param)
}
unusedPath, err := nextAvailableDrivePreviewPath(runtime.FileIO(), "fresh.pdf")
if err != nil {
t.Fatalf("nextAvailableDrivePreviewPath(unused) error: %v", err)
}
if unusedPath != "fresh.pdf" {
t.Fatalf("unusedPath=%q, want fresh.pdf", unusedPath)
}
overwritten, _, err := resolveDrivePreviewOutputPath(runtime, "preview.pdf", header, ".pdf", drivePreviewIfExistsOverwrite)
if err != nil {
t.Fatalf("resolveDrivePreviewOutputPath(overwrite) error: %v", err)
}
if !strings.HasSuffix(overwritten, "preview.pdf") {
t.Fatalf("overwritten=%q, want preview.pdf suffix", overwritten)
}
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
f.FileIOProvider = &statErrorProvider{inner: f.FileIOProvider, err: fs.ErrPermission}
runtimeWithStatErr := newDrivePreviewRuntime(t, "drive +preview", nil, nil)
runtimeWithStatErr.Factory = f
_, _, err = resolveDrivePreviewOutputPath(runtimeWithStatErr, "blocked.pdf", header, ".pdf", drivePreviewIfExistsError)
if err == nil {
t.Fatal("expected stat permission error, got nil")
}
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)
}
}
type drivePreviewTestStringer string
type statErrorProvider struct {
inner fileio.Provider
err error
}
func (p *statErrorProvider) Name() string { return "stat-error" }
func (p *statErrorProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
return &statErrorFileIO{inner: p.inner.ResolveFileIO(ctx), err: p.err}
}
type statErrorFileIO struct {
inner fileio.FileIO
err error
}
func (f *statErrorFileIO) Open(name string) (fileio.File, error) { return f.inner.Open(name) }
func (f *statErrorFileIO) Stat(string) (fileio.FileInfo, error) { return nil, f.err }
func (f *statErrorFileIO) ResolvePath(path string) (string, error) { return f.inner.ResolvePath(path) }
func (f *statErrorFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
return f.inner.Save(path, opts, body)
}
// String implements fmt.Stringer for scalar helper tests.
func (s drivePreviewTestStringer) String() string { return string(s) }
// TestDrivePreviewScalarHelpers verifies scalar coercion helpers normalize
// mixed API field types into strings.
func TestDrivePreviewScalarHelpers(t *testing.T) {
got := firstString(map[string]interface{}{
"blank": " ",
"number": float64(7),
"flag": true,
"named": drivePreviewTestStringer(" named "),
"integer": int64(9),
}, "blank", "named", "number")
if got != "named" {
t.Fatalf("firstString()=%q, want named", got)
}
if got := firstString(map[string]interface{}{"flag": true}, "flag"); got != "true" {
t.Fatalf("firstString(bool)=%q, want true", got)
}
if got := firstString(map[string]interface{}{"integer": int64(9)}, "integer"); got != "9" {
t.Fatalf("firstString(int64)=%q, want 9", got)
}
if got := versionString(" 42 "); got != "42" {
t.Fatalf("versionString(string)=%q, want 42", got)
}
if got := versionString(float64(8)); got != "8" {
t.Fatalf("versionString(float64)=%q, want 8", got)
}
if got := versionString(int64(11)); got != "11" {
t.Fatalf("versionString(int64)=%q, want 11", got)
}
if got := versionString(struct{}{}); got != "" {
t.Fatalf("versionString(struct)=%q, want empty", got)
}
}
// TestDrivePreviewAliasAndAvailabilityHelpers verifies alias lookup,
// normalization, and available-type de-duplication helpers.
func TestDrivePreviewAliasAndAvailabilityHelpers(t *testing.T) {
if got := normalizeDrivePreviewRequest(" Source File "); got != "source_file" {
t.Fatalf("normalizeDrivePreviewRequest()=%q, want source_file", got)
}
aliases := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "1"})
if len(aliases) == 0 || aliases[0] != "image" {
t.Fatalf("previewAliasesForCandidate()=%v, want image alias", aliases)
}
if got := previewAliasesForCandidate(drivePreviewCandidate{TypeCode: "999"}); got != nil {
t.Fatalf("previewAliasesForCandidate(unknown)=%v, want nil", got)
}
types := availableDrivePreviewTypes([]drivePreviewCandidate{
{Type: "pdf"},
{Type: "pdf"},
{Type: " jpg "},
{Type: ""},
})
if len(types) != 2 || types[0] != "pdf" || types[1] != "jpg" {
t.Fatalf("availableDrivePreviewTypes()=%v, want [pdf jpg]", types)
}
}
// TestDrivePreviewUnavailableHintAndContentTypeFallback verifies unavailable
// preview errors and content-type fallback extension inference.
func TestDrivePreviewUnavailableHintAndContentTypeFallback(t *testing.T) {
err := wrapDrivePreviewUnavailable("file_preview", "html", []drivePreviewCandidate{
{Type: "pdf"},
{Type: "jpg"},
}, "")
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(validationErr.Hint, "available preview types: pdf, jpg") {
t.Fatalf("hint=%q, want available preview types", validationErr.Hint)
}
err = wrapDrivePreviewUnavailable("file_preview", "html", nil, fmt.Sprintf("custom reason for %s", "html"))
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(validationErr.Hint, "--list-only") {
t.Fatalf("hint=%q, want list-only guidance", validationErr.Hint)
}
resolution := drivePreviewExtensionByContentType("text/plain; charset=utf-8")
if resolution == nil || resolution.Ext != ".txt" {
t.Fatalf("drivePreviewExtensionByContentType()=%+v, want .txt", resolution)
}
}

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

@@ -12,8 +12,6 @@ func Shortcuts() []common.Shortcut {
DriveCreateFolder,
DriveCreateShortcut,
DriveDownload,
DrivePreview,
DriveCover,
DriveAddComment,
DriveExport,
DriveExportDownload,

View File

@@ -15,8 +15,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+create-folder",
"+create-shortcut",
"+download",
"+preview",
"+cover",
"+version-history",
"+version-get",
"+version-revert",

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