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
520 changed files with 12673 additions and 98921 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/ - path: internal/vfs/
linters: linters:
- forbidigo - 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 # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer. # for the client / credential layer.
- path-except: shortcuts/ - path-except: shortcuts/
@@ -73,20 +65,20 @@ linters:
- forbidigo - forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError. # errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete. # 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/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/) - 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 text: errs-typed-only
linters: linters:
- forbidigo - forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final # errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/, # errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught. # cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go) - path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap text: errs-no-bare-wrap
linters: linters:
- forbidigo - forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save # errs-no-legacy-helper is drive-only: the shared helpers it bans are
# helpers have migrated to typed final errors. # still used by other domains until their later migration phase.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/) - path-except: (shortcuts/drive/)
text: errs-no-legacy-helper text: errs-no-legacy-helper
linters: linters:
- forbidigo - forbidigo
@@ -115,15 +107,17 @@ linters:
msg: >- msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder [errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go). (see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ── # ── legacy shared error helpers banned on drive ──
# These helpers emit legacy output.Err* / bare error shapes or drop # These helpers internally produce legacy output.Err* shapes, so they
# typed metadata such as Param/Cause. Migrated domains must use typed # are invisible to the errs-typed-only ban above. Drive has migrated its
# common replacements or local typed helpers instead. # calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b # 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: >- msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy or [errs-no-legacy-helper] these shared helpers emit legacy output.Err*
metadata-poor error shapes. Use typed common replacements, typed shapes. Use the typed errs.NewXxxError builders or the drive-local
errs.NewXxxError builders, or domain-local typed helpers. driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ── # ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b - pattern: (fmt\.Errorf|errors\.New)\b
msg: >- msg: >-

View File

@@ -2,43 +2,6 @@
All notable changes to this project will be documented in this file. 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 ## [v1.0.46] - 2026-06-02
### Features ### Features
@@ -1026,8 +989,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - 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.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.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44 [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.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().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().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().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().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)") 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") 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) // 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() { for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
continue continue
} }
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) { if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) { for _, s := range sc.DeclaredScopesForIdentity(identity) {
scopeSet[s] = true scopeSet[s] = true
} }
@@ -557,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
} }
} }
for _, sc := range shortcuts.AllShortcuts() { for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
continue continue
} }
if !registry.HasAuthDomain(sc.Service) { if !registry.HasAuthDomain(sc.GetService()) {
domains[sc.Service] = true domains[sc.GetService()] = true
} }
} }
return domains return domains
@@ -580,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot"). // shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"]. // Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool { func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
authTypes := sc.AuthTypes authTypes := sc.GetAuthTypes()
if len(authTypes) == 0 { if len(authTypes) == 0 {
authTypes = []string{"user"} authTypes = []string{"user"}
} }

View File

@@ -64,12 +64,13 @@ func getDomainMetadata(lang string) []domainMeta {
shortcutOnlySet[n] = true shortcutOnlySet[n] = true
} }
for _, sc := range shortcuts.AllShortcuts() { for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] { svc := sc.GetService()
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) { if !seen[svc] {
dm := buildDomainMeta(sc.Service, lang) if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
dm := buildDomainMeta(svc, lang)
domains = append(domains, dm) 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) { func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"] // Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil} sc := &common.Shortcut{AuthTypes: nil}
if !shortcutSupportsIdentity(sc, "user") { if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected default to support 'user'") t.Error("expected default to support 'user'")
} }
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
} }
func TestShortcutSupportsIdentity_ExplicitTypes(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") { if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected to support 'user'") t.Error("expected to support 'user'")
} }
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
} }
func TestShortcutSupportsIdentity_BotOnly(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") { if shortcutSupportsIdentity(sc, "user") {
t.Error("expected bot-only to NOT support 'user'") t.Error("expected bot-only to NOT support 'user'")
} }

View File

@@ -6,7 +6,6 @@ package cmd
import ( import (
"context" "context"
"io" "io"
"io/fs"
"github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/auth"
@@ -17,7 +16,6 @@ import (
"github.com/larksuite/cli/cmd/profile" "github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update" cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events" _ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
@@ -53,18 +51,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
} }
} }
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the
// repo-root package main's init; a wrapper main can call it before Execute to
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// HideProfile sets the visibility policy for the root-level --profile flag. // HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still // When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as // parse) but is omitted from help and shell completion. Typically called as
@@ -117,7 +103,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
if cfg.keychain != nil { if cfg.keychain != nil {
f.Keychain = cfg.keychain f.Keychain = cfg.keychain
} }
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{ rootCmd := &cobra.Command{
Use: "lark-cli", Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -132,13 +117,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
installTipsHelpFunc(rootCmd) installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true 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) RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
@@ -155,7 +133,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)

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

View File

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

View File

@@ -10,7 +10,6 @@ import (
eventlib "github.com/larksuite/cli/internal/event" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
) )
const maxSuggestions = 3 const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0}) hits = append(hits, match{def.Key, 0})
continue 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}) 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.", "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" _ "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) { func TestSuggestEventKeys(t *testing.T) {
cases := []struct { cases := []struct {
name string 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/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
const rootLong = `lark-cli — Lark/Feishu CLI tool. const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -72,15 +69,7 @@ COMMUNITY:
More help: lark-cli <command> --help` More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code. // 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 { func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:]) inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err) fmt.Fprintln(os.Stderr, "Error:", err)
@@ -144,49 +133,29 @@ func setupNotices() {
skillscheck.Init(build.Version) skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set. // Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice output.PendingNotice = func() map[string]interface{} {
} notice := map[string]interface{}{}
if info := update.GetPending(); info != nil {
// composePendingNotice merges all process-level pending notices (available notice["update"] = map[string]interface{}{
// update, skills/binary drift, deprecated-command alias) into the map surfaced "current": info.Current,
// as the JSON "_notice" envelope field. Returns nil when nothing is pending. "latest": info.Latest,
// Extracted from Execute so the composition is unit-testable. "message": info.Message(),
func composePendingNotice() map[string]interface{} { "command": "lark-cli update",
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. // 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 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) fmt.Fprintln(errOut, "Error:", err)
return 1 return 1
} }
@@ -345,12 +301,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) { func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE 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 { if cmd.Annotations == nil {
cmd.Annotations = map[string]string{} cmd.Annotations = map[string]string{}
} }
@@ -370,89 +320,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface. // they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 { if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags return cmd.Help()
// 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{},
},
},
}
} }
unknown := args[0] unknown := args[0]
available, deprecated := availableSubcommandNames(cmd) available := 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)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 { if len(available) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
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
} }
return &output.ExitError{ return &output.ExitError{
Code: output.ExitValidation, Code: output.ExitValidation,
@@ -460,114 +335,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Type: "unknown_subcommand", Type: "unknown_subcommand",
Message: msg, Message: msg,
Hint: hint, 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 func availableSubcommandNames(cmd *cobra.Command) []string {
// rawArgs, stopping at the "--" positional terminator. Whether a flag is subs := make([]string, 0, len(cmd.Commands()))
// 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) {
for _, c := range cmd.Commands() { for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() { if c.Hidden || !c.IsAvailableCommand() {
continue continue
@@ -576,95 +354,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
if name == "help" || name == "completion" { if name == "help" || name == "completion" {
continue continue
} }
if cmdutil.IsDeprecatedCommand(c) { subs = append(subs, name)
deprecated = append(deprecated, name)
} else {
available = append(available, name)
}
} }
sort.Strings(available) sort.Strings(subs)
sort.Strings(deprecated) return subs
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
} }
// installTipsHelpFunc wraps the default help function to append a TIPS section // installTipsHelpFunc wraps the default help function to append a TIPS section

View File

@@ -377,9 +377,9 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
OK: false, OK: false,
Identity: "bot", Identity: "bot",
Error: &output.ErrDetail{ Error: &output.ErrDetail{
Type: "api", Type: "api_error",
Code: 230002, Code: 230002,
Message: "Bot/User can NOT be out of the chat.", Message: "HTTP 400: Bot/User can NOT be out of the chat.",
}, },
}) })
} }

View File

@@ -21,7 +21,6 @@ import (
internalauth "github.com/larksuite/cli/internal/auth" internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/registry"
) )
@@ -269,54 +268,6 @@ func (f *failingWriter) Write(p []byte) (int, error) {
return len(p), nil 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 // TestHandleRootError_PartialWritePreservesExitCode pins that when the
// stderr write fails mid-envelope, handleRootError still returns the typed // stderr write fails mid-envelope, handleRootError still returns the typed
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the // 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.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().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().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().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().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
if risk == "high-risk-write" { 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

@@ -1,183 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skill implements the `lark-cli skills` command group, which serves
// binary-embedded skill content to AI agents. The package is "skill"; the
// user-facing verb is "skills".
package skill
import (
"fmt"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillcontent"
"github.com/spf13/cobra"
)
func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) {
if f.SkillContent == nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"skill content not embedded in this build")
}
return skillcontent.New(f.SkillContent), nil
}
type readEnvelope struct {
Skill string `json:"skill"`
Path string `json:"path"`
Content string `json:"content"`
Guidance string `json:"guidance,omitempty"`
}
type listEnvelope struct {
OK bool `json:"ok"`
Skills []skillcontent.SkillInfo `json:"skills"`
Count int `json:"count"`
}
type listPathEnvelope struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []skillcontent.DirEntry `json:"entries"`
Count int `json:"count"`
}
func NewCmdSkill(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "skills",
Short: "Read embedded skill content (list / read)",
Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " +
"the CLI binary at build time, so it stays in sync with the CLI version. " +
"Machine resources such as assets/ and scripts/ are not embedded.",
}
// Risk is set on each leaf (GetRisk does not walk parents); the group has none.
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(newListCmd(f), newReadCmd(f))
return cmd
}
func newListCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "list [name[/path]]",
Short: "List skills, or list one layer under a skill path (like ls)",
Example: ` lark-cli skills list # all skills: name, description, version
lark-cli skills list lark-doc # one layer under a skill (like ls)
lark-cli skills list lark-doc/references # one layer under a subdirectory`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"list takes at most 1 argument: [name[/path]]").
WithHint("run 'lark-cli skills list --help'")
}
r, err := newReader(f)
if err != nil {
return err
}
if len(args) == 0 {
skills, err := r.List()
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)})
return nil
}
entries, listed, err := r.ListPath(args[0])
if err != nil {
return err
}
output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)})
return nil
},
}
// --json is a no-op (list is always JSON), accepted only to stay symmetric with read.
cmd.Flags().Bool("json", false, "no-op (list output is always JSON)")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
func newReadCmd(f *cmdutil.Factory) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "read <name>[/<path>] [path]",
Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)",
Example: ` lark-cli skills read lark-doc # the skill's SKILL.md
lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill
lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form
lark-cli skills read lark-doc --json # JSON envelope`,
Args: cobra.ArbitraryArgs,
RunE: func(cmd *cobra.Command, args []string) error {
name, relpath, err := parseReadTarget(args)
if err != nil {
return err
}
r, err := newReader(f)
if err != nil {
return err
}
var content []byte
var pathOut string
if relpath == "" {
content, err = r.ReadSkill(name)
pathOut = "SKILL.md"
} else {
content, pathOut, err = r.ReadReference(name, relpath)
}
if err != nil {
return err
}
isMain := pathOut == "SKILL.md"
if asJSON {
env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)}
if isMain {
env.Guidance = readGuidance(name)
}
output.PrintJson(f.IOStreams.Out, env)
return nil
}
// Raw stdout stays byte-identical to the file; guidance goes to stderr.
if _, err := f.IOStreams.Out.Write(content); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err)
}
if isMain {
fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name))
}
return nil
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown")
cmdutil.SetRisk(cmd, "read")
cmdutil.DisableAuthCheck(cmd)
return cmd
}
// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone
// "<a>/<b>" splits on the first '/', and relpath "" reads the main SKILL.md.
func parseReadTarget(args []string) (name, relpath string, err error) {
switch len(args) {
case 1:
name, relpath = skillcontent.SplitArg(args[0])
return name, relpath, nil
case 2:
return args[0], args[1], nil
default:
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"read requires 1 or 2 arguments: <name>[/<path>] [path]").
WithHint("run 'lark-cli skills read --help'")
}
}
// readGuidance routes cross-skill "../lark-foo/..." references back through
// `skills read lark-foo/...`: the path guard rejects a literal "../", so the
// relative form must be rewritten.
func readGuidance(name string) string {
return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+
"`lark-cli skills read %s <relative-path>` to keep them in sync with this CLI version. "+
"A reference to another skill (`../lark-foo/...`) uses the same command with the "+
"leading `../` removed: `lark-cli skills read lark-foo/...`.", name)
}

View File

@@ -1,306 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skill
import (
"encoding/json"
"io"
"io/fs"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
)
// calFS is the default single-skill content tree for these tests. The embedded
// FS is now injected through the Factory (no package global), so tests pass it
// explicitly to run() — nothing is shared, so they are safe under -parallel.
func calFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
}
}
// run executes the skills command tree against the given content FS (may be nil
// to exercise the not-embedded path) and returns stdout/stderr/err.
func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) {
t.Helper()
// Isolate CLI config state so tests never read/write the real config dir
// (repo convention).
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, out, errOut, _ := cmdutil.TestFactory(t, nil)
f.SkillContent = fsys
cmd := NewCmdSkill(f)
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
err = cmd.Execute()
return out.String(), errOut.String(), err
}
func TestSkillList(t *testing.T) {
stdout, _, err := run(t, calFS(), "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
// "ok" is an explicit success marker (the list envelope is a typed struct;
// no automatic _notice attaches).
if !got.OK {
t.Error("expected ok=true in list envelope")
}
if got.Count != 1 || len(got.Skills) != 1 {
t.Fatalf("count: got %d", got.Count)
}
if got.Skills[0]["name"] != "lark-calendar" {
t.Errorf("name: got %v", got.Skills[0]["name"])
}
// Top-level list carries version + metadata, not a references list.
if _, ok := got.Skills[0]["references"]; ok {
t.Error("top-level list must not include references")
}
if got.Skills[0]["version"] != "1.0.0" {
t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"])
}
if _, ok := got.Skills[0]["metadata"]; !ok {
t.Error("expected metadata in list entry")
}
}
func TestSkillListJSONFlagAccepted(t *testing.T) {
// `list --json` must be accepted (no-op), not rejected as an unknown flag,
// so it stays symmetric with read --json.
stdout, _, err := run(t, calFS(), "list", "--json")
if err != nil {
t.Fatalf("list --json error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Count != 1 {
t.Errorf("envelope: %+v", got)
}
}
func TestSkillListPath(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar")
if err != nil {
t.Fatalf("list <name> error: %v", err)
}
var got struct {
OK bool `json:"ok"`
Path string `json:"path"`
Entries []struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
} `json:"entries"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if !got.OK || got.Path != "lark-calendar" {
t.Errorf("envelope: %+v", got)
}
// One layer under the skill root: SKILL.md (file) + references (dir).
if got.Count != 2 || len(got.Entries) != 2 {
t.Fatalf("entries: got %+v", got.Entries)
}
if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir {
t.Errorf("entry[0]: got %+v", got.Entries[0])
}
if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir {
t.Errorf("entry[1]: got %+v", got.Entries[1])
}
}
func TestSkillListPathUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "list", "no-such-skill")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("expected 'unknown skill' error, got %v", err)
}
}
func TestSkillListPathTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc")
if err == nil || !strings.Contains(err.Error(), "invalid path") {
t.Fatalf("expected 'invalid path' error, got %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillListTooManyArgs(t *testing.T) {
_, _, err := run(t, calFS(), "list", "a", "b")
if err == nil || !strings.Contains(err.Error(), "at most 1 argument") {
t.Fatalf("expected 'at most 1 argument' error, got %v", err)
}
}
// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is
// omitted from the catalog (no blank entry).
func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) {
fsys := fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")},
"not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md
}
stdout, _, err := run(t, fsys, "list")
if err != nil {
t.Fatalf("list error: %v", err)
}
var got struct {
Skills []map[string]any `json:"skills"`
Count int `json:"count"`
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" {
t.Fatalf("expected only lark-calendar, got %+v", got.Skills)
}
}
func TestSkillReadRaw(t *testing.T) {
stdout, stderr, err := run(t, calFS(), "read", "lark-calendar")
if err != nil {
t.Fatalf("read error: %v", err)
}
if !strings.HasPrefix(stdout, "---\nname: lark-calendar") {
t.Errorf("raw output: got %q", stdout)
}
// Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended.
if strings.Contains(stdout, "Tip:") {
t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout)
}
// Guidance goes to stderr: own files via `skills read <name> ...`, and
// cross-skill refs routed to `skills read <other-skill> ...` (version-
// consistent), not "read directly".
if !strings.Contains(stderr, "lark-cli skills read lark-calendar <relative-path>") {
t.Errorf("expected own-files guidance on stderr: got %q", stderr)
}
if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") {
t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr)
}
if strings.Contains(stderr, "instead of opening them directly") ||
strings.Contains(stderr, "read those directly") {
t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr)
}
}
func TestSkillReadJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json")
if err != nil {
t.Fatalf("read --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v", e)
}
if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" {
t.Errorf("envelope: %+v", got)
}
// Guidance is a separate field, not merged into content.
if got.Guidance == "" {
t.Error("expected guidance field for main SKILL.md")
}
if strings.Contains(got.Content, "Tip:") {
t.Error("guidance must not be merged into content")
}
}
func TestSkillReadFile(t *testing.T) {
// Both the 2-arg and slash forms read the same file, with no guidance tip.
for _, args := range [][]string{
{"read", "lark-calendar", "references/agenda.md"},
{"read", "lark-calendar/references/agenda.md"},
} {
stdout, stderr, err := run(t, calFS(), args...)
if err != nil {
t.Fatalf("read %v error: %v", args, err)
}
if stdout != "# Agenda" {
t.Errorf("read %v output: got %q", args, stdout)
}
// Reference reads carry no guidance on either stream.
if strings.Contains(stderr, "Tip:") {
t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr)
}
}
}
func TestSkillReadFileJSON(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json")
if err != nil {
t.Fatalf("read file --json error: %v", err)
}
var got struct {
Skill, Path, Content, Guidance string
}
if e := json.Unmarshal([]byte(stdout), &got); e != nil {
t.Fatalf("invalid JSON: %v\n%s", e, stdout)
}
if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" {
t.Errorf("envelope: %+v", got)
}
// Reference reads do not carry the guidance tip.
if got.Guidance != "" {
t.Errorf("reference read must not include guidance, got %q", got.Guidance)
}
}
func TestSkillReadUnknown(t *testing.T) {
_, _, err := run(t, calFS(), "read", "no-such")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown skill") {
t.Errorf("err: %v", err)
}
}
func TestSkillReadMissingArg(t *testing.T) {
_, _, err := run(t, calFS(), "read")
if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") {
t.Fatalf("expected arg error, got %v", err)
}
}
func TestSkillReadTraversal(t *testing.T) {
stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd")
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "invalid path") {
t.Errorf("err: %v", err)
}
if stdout != "" {
t.Errorf("stdout must be empty on rejection, got %q", stdout)
}
}
func TestSkillNilContentFS(t *testing.T) {
_, _, err := run(t, nil, "list")
if err == nil {
t.Fatal("expected error when SkillContent is nil")
}
if !strings.Contains(err.Error(), "not embedded") {
t.Errorf("err: %v", err)
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output" "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) { func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree() _, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root()) installUnknownSubcommandGuard(drive.Root())
@@ -257,11 +113,11 @@ func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) { if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message) 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 if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
// back to pointing at --help; the full machine-readable list lives in t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
// detail.available below (which also excludes hidden commands). }
if !strings.Contains(exitErr.Detail.Hint, "--help") { if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Errorf("hint should guide to --help when there is no suggestion, got %q", exitErr.Detail.Hint) t.Error("hidden commands must not appear in the hint")
} }
detail, ok := exitErr.Detail.Detail.(map[string]any) 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 }}, &cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
) )
got, _ := availableSubcommandNames(root) got := availableSubcommandNames(root)
want := []string{"alpha", "gamma"} want := []string{"alpha", "gamma"}
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got) 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

@@ -49,29 +49,18 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result } u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil } u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand() u.SkillsCommandOverride = successfulSkillsCommand()
return u return u
} }
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
} }
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
return r
}
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult { func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult { return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") { switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list": case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n") 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": case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n") r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default: default:
@@ -487,10 +476,6 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") } u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false } u.RestoreAvailableOverride = func() bool { return false }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails") t.Fatal("skills sync should not run when binary verification fails")
return nil return nil
@@ -823,11 +808,6 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
} }
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil } u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found") r.Stderr.WriteString("npx: command not found")
@@ -880,11 +860,6 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
} }
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} } u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil } u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult { u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found") r.Stderr.WriteString("npx: command not found")
@@ -1029,7 +1004,6 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater { newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{ return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true skillsCalled = true
return successfulSkillsCommand()(args...) return successfulSkillsCommand()(args...)
@@ -1068,7 +1042,6 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater { newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{ return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult { DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{ return selfupdate.DetectResult{
Method: selfupdate.InstallManual, Method: selfupdate.InstallManual,
@@ -1113,7 +1086,6 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater { newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{ return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult { DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{ return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true, Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1173,10 +1145,6 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult { DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true} return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
}, },
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true skillsCalled = true
return successfulSkillsCommand()(args...) return successfulSkillsCommand()(args...)
@@ -1226,10 +1194,6 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater { newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{ return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult { SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true skillsCalled = true
return successfulSkillsCommand()(args...) return successfulSkillsCommand()(args...)

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

View File

@@ -8,7 +8,6 @@ import (
"github.com/larksuite/cli/events/im" "github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes" "github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/vc" "github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event" "github.com/larksuite/cli/internal/event"
) )
@@ -18,7 +17,6 @@ func init() {
im.Keys(), im.Keys(),
minutes.Keys(), minutes.Keys(),
vc.Keys(), vc.Keys(),
whiteboard.Keys(),
} }
for _, keys := range all { for _, keys := range all {
for _, k := range keys { for _, k := range keys {

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
// BoardWhiteboardUpdatedV1Data is the flattened whiteboard updated source payload.
type BoardWhiteboardUpdatedV1Data struct {
// WhiteboardID is the id of the whiteboard whose content was updated.
WhiteboardID string `json:"whiteboard_id"`
// OperatorIDs lists the operators that produced this update batch.
OperatorIDs []OperatorID `json:"operator_ids"`
}
// OperatorID identifies an operator that produced the whiteboard update,
// expressed in the three Lark identity formats.
type OperatorID struct {
// OpenID is the operator's open_id within the current app.
OpenID string `json:"open_id"`
// UnionID is the operator's union_id across apps under the same ISV.
UnionID string `json:"union_id"`
// UserID is the operator's user_id within the tenant.
UserID string `json:"user_id"`
}

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"time"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/validate"
)
// cleanupTimeout bounds how long the unsubscribe call has to finish during
// PreConsume cleanup so a stuck OAPI cannot block process shutdown.
const cleanupTimeout = 5 * time.Second
// whiteboardSubscriptionPreConsume calls the whiteboard event subscribe OAPI
// and returns a cleanup that invokes the matching unsubscribe.
//
// board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id),
// so the path contains a :whiteboard_id placeholder that must be supplied via params.
func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) {
if rt == nil {
return nil, fmt.Errorf("runtime API client is required for pre-consume subscription")
}
whiteboardID := params["whiteboard_id"]
if whiteboardID == "" {
return nil, fmt.Errorf("param whiteboard_id is required for %s", eventType)
}
encoded := validate.EncodePathSegment(whiteboardID)
subscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/subscribe", encoded)
unsubscribePath := fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/unsubscribe", encoded)
body := map[string]string{"event_type": eventType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, err
}
return func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
defer cancel()
_, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body)
}, nil
}
}

View File

@@ -1,198 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/internal/event"
)
// recordedCall captures a single APIClient invocation for assertion.
type recordedCall struct {
method string
path string
body interface{}
}
// fakeAPIClient is a minimal event.APIClient stub that records calls and
// can be configured to fail when the request path matches errOnPath.
type fakeAPIClient struct {
mu sync.Mutex
calls []recordedCall
errOnPath string
}
// CallAPI records the invocation and optionally returns a simulated error
// when the path contains the configured errOnPath substring.
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.errOnPath != "" && strings.Contains(path, f.errOnPath) {
return nil, errors.New("simulated subscribe failure")
}
return json.RawMessage(`{}`), nil
}
// TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID verifies that the
// PreConsume hook fails fast with an actionable error when whiteboard_id
// is absent from the params map.
func TestWhiteboardSubscriptionPreConsume_MissingWhiteboardID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{})
if err == nil {
t.Fatalf("expected error when whiteboard_id missing")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup on error")
}
if !strings.Contains(err.Error(), "whiteboard_id") {
t.Fatalf("error should mention whiteboard_id, got: %v", err)
}
}
// TestWhiteboardSubscriptionPreConsume_NilRuntime verifies that PreConsume
// returns an error when the runtime APIClient dependency is missing.
func TestWhiteboardSubscriptionPreConsume_NilRuntime(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
_, err := pc(context.Background(), nil, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error when runtime client is nil")
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeError verifies that a
// failed subscribe call surfaces the error and skips registering a cleanup,
// so no spurious unsubscribe is invoked.
func TestWhiteboardSubscriptionPreConsume_SubscribeError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{errOnPath: "/subscribe"}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err == nil {
t.Fatalf("expected error from subscribe call")
}
if cleanup != nil {
t.Fatalf("expected nil cleanup when subscribe fails")
}
// only the failed subscribe call should have been made; no unsubscribe.
if len(rt.calls) != 1 {
t.Fatalf("expected exactly 1 call (subscribe), got %d", len(rt.calls))
}
}
// TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup verifies the full
// happy-path: subscribe is called once with the correct method/path/body,
// and the returned cleanup invokes the matching unsubscribe.
func TestWhiteboardSubscriptionPreConsume_SubscribeAndCleanup(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cleanup == nil {
t.Fatalf("expected non-nil cleanup")
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call after subscribe, got %d", len(rt.calls))
}
got := rt.calls[0]
if got.method != "POST" {
t.Errorf("subscribe method: got %q, want POST", got.method)
}
wantSubPath := "/open-apis/board/v1/whiteboards/wb1/subscribe"
if got.path != wantSubPath {
t.Errorf("subscribe path: got %q, want %q", got.path, wantSubPath)
}
body, _ := got.body.(map[string]string)
if body["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("subscribe body event_type: got %q, want %q", body["event_type"], eventTypeWhiteboardUpdated)
}
cleanup()
if len(rt.calls) != 2 {
t.Fatalf("expected 2 calls after cleanup, got %d", len(rt.calls))
}
got2 := rt.calls[1]
if got2.method != "POST" {
t.Errorf("unsubscribe method: got %q, want POST", got2.method)
}
wantUnsubPath := "/open-apis/board/v1/whiteboards/wb1/unsubscribe"
if got2.path != wantUnsubPath {
t.Errorf("unsubscribe path: got %q, want %q", got2.path, wantUnsubPath)
}
body2, _ := got2.body.(map[string]string)
if body2["event_type"] != eventTypeWhiteboardUpdated {
t.Errorf("unsubscribe body event_type: got %q, want %q", body2["event_type"], eventTypeWhiteboardUpdated)
}
}
// TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded verifies that
// whiteboard_id values containing reserved URL characters are properly
// path-segment encoded so they cannot escape into adjacent path segments.
func TestWhiteboardSubscriptionPreConsume_PathSegmentEncoded(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
pc := whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated)
rt := &fakeAPIClient{}
// 含特殊字符的 whiteboard_id 应被 path-segment 编码,避免越界到其他 path 段。
_, err := pc(context.Background(), rt, map[string]string{"whiteboard_id": "wb/1?evil"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rt.calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(rt.calls))
}
if strings.Contains(rt.calls[0].path, "wb/1?evil") {
t.Errorf("whiteboard_id was not encoded; path: %s", rt.calls[0].path)
}
}
// TestWhiteboardUpdatedV1HasPreConsume ensures the registered EventKey for
// board.whiteboard.updated_v1 wires the PreConsume hook and declares the
// required whiteboard_id parameter.
func TestWhiteboardUpdatedV1HasPreConsume(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
keys := Keys()
for _, k := range keys {
if k.Key == eventTypeWhiteboardUpdated {
if k.PreConsume == nil {
t.Fatalf("EventKey %s should have PreConsume hook", eventTypeWhiteboardUpdated)
}
if len(k.Params) == 0 {
t.Fatalf("EventKey %s should declare whiteboard_id param", eventTypeWhiteboardUpdated)
}
var found bool
for _, p := range k.Params {
if p.Name == "whiteboard_id" && p.Required {
found = true
}
}
if !found {
t.Fatalf("EventKey %s must declare required whiteboard_id param", eventTypeWhiteboardUpdated)
}
return
}
}
t.Fatalf("EventKey %s not registered", eventTypeWhiteboardUpdated)
}
// 确保 event.APIClient 接口与本测试 mock 一致。
var _ event.APIClient = (*fakeAPIClient)(nil)

View File

@@ -1,48 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package whiteboard registers Board-domain EventKeys.
package whiteboard
import (
"reflect"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
// eventTypeWhiteboardUpdated is the OAPI event type for whiteboard content updates.
const eventTypeWhiteboardUpdated = "board.whiteboard.updated_v1"
// Keys returns all Board-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeWhiteboardUpdated,
DisplayName: "Whiteboard updated",
Description: "Pushed when the whiteboard content is updated.",
EventType: eventTypeWhiteboardUpdated,
Params: []event.ParamDef{
{
Name: "whiteboard_id",
Type: event.ParamString,
Required: true,
Description: "Whiteboard id to subscribe; subscription is per-whiteboard.",
},
},
Schema: event.SchemaDef{
Native: &event.SchemaSpec{Type: reflect.TypeOf(BoardWhiteboardUpdatedV1Data{})},
FieldOverrides: map[string]schemas.FieldMeta{
"/event/whiteboard_id": {Kind: "whiteboard_id", Description: "whiteboard id to subscribe"},
"/event/operator_ids/*/open_id": {Kind: "open_id"},
"/event/operator_ids/*/union_id": {Kind: "union_id"},
"/event/operator_ids/*/user_id": {Kind: "user_id"},
},
},
PreConsume: whiteboardSubscriptionPreConsume(eventTypeWhiteboardUpdated),
Scopes: []string{"board:whiteboard:node:read"},
AuthTypes: []string{"user", "bot"},
RequiredConsoleEvents: []string{eventTypeWhiteboardUpdated},
},
}
}

2
go.mod
View File

@@ -14,7 +14,7 @@ require (
github.com/sergi/go-diff v1.4.0 github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1 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/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0 github.com/tidwall/gjson v1.18.0

View File

@@ -5,7 +5,6 @@ package cmdpolicy
import ( import (
"github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/suggest"
) )
// suggestRisk returns the closest valid Risk literal by edit distance // 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, platform.RiskRead, platform.RiskWrite, platform.RiskHighRiskWrite,
} }
best := string(candidates[0]) best := string(candidates[0])
bestDist := suggest.Levenshtein(lowered, best) bestDist := levenshtein(lowered, best)
for _, c := range candidates[1:] { 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) bestDist, best = d, string(c)
} }
} }
@@ -41,3 +40,47 @@ func toLower(s string) string {
} }
return string(b) 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

@@ -6,7 +6,6 @@ package cmdutil
import ( import (
"context" "context"
"io" "io"
"io/fs"
"net/http" "net/http"
"strings" "strings"
@@ -44,8 +43,6 @@ type Factory struct {
Credential *credential.CredentialProvider Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem) FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
} }
// ResolveFileIO resolves a FileIO instance using the current execution context. // ResolveFileIO resolves a FileIO instance using the current execution context.

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

@@ -92,18 +92,6 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
base.Troubleshooter = ts base.Troubleshooter = ts
} }
} }
// Upstream-provided field-level reasons (resp.error.details[].value). Lark
// returns these as free-text reason strings with no machine-readable field
// name (verified for code 190014:
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}),
// so they are lifted into Problem.Hint — the sanctioned free-text recovery
// prompt — rather than fabricated structured params. Lifted before the
// category switch so any classified arm inherits it; the CategoryAPI arm
// below prefers this server detail over the context-free APIHint default.
detailHint := liftErrorDetailValues(resp)
if detailHint != "" {
base.Hint = detailHint
}
switch meta.Category { switch meta.Category {
case errs.CategoryAuthorization: case errs.CategoryAuthorization:
@@ -141,11 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action, Action: action,
} }
case errs.CategoryAPI: case errs.CategoryAPI:
// A server-supplied detail (lifted into base.Hint above) wins over the base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
// context-free APIHint default; only fall back to APIHint when absent.
if base.Hint == "" {
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
}
return &errs.APIError{Problem: base} return &errs.APIError{Problem: base}
default: default:
// Fail closed: an unrecognized Category routes to InternalError // Fail closed: an unrecognized Category routes to InternalError
@@ -230,10 +214,6 @@ func stringFromAny(v any) string {
// per-subtype recovery hint before returning it, so the wire envelope // per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes. // emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) *errs.ConfigError { func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
p.Hint = ConfigHint(p.Subtype) p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p} return &errs.ConfigError{Problem: p}
} }
@@ -264,8 +244,6 @@ func APIHint(subtype errs.Subtype) string {
return "operate on source and target within the same tenant and region/unit" return "operate on source and target within the same tenant and region/unit"
case errs.SubtypeCrossBrand: case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment" 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 "" return ""
} }
@@ -278,10 +256,6 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
} }
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing) consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message) p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
// Permission categories have authoritative recovery guidance (scopes to
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL) p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{ permErr := &errs.PermissionError{
Problem: p, Problem: p,
@@ -390,32 +364,6 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
return "check the calling identity has the required scope" return "check the calling identity has the required scope"
} }
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
// strings and joins them with "; ". Returns "" when the structure is absent or
// carries no non-empty value. The shape (verified for code 190014) is
// {"error":{"details":[{"value":"<reason>"}]}}.
func liftErrorDetailValues(resp map[string]any) string {
errBlock, ok := resp["error"].(map[string]any)
if !ok {
return ""
}
details, ok := errBlock["details"].([]any)
if !ok || len(details) == 0 {
return ""
}
var values []string
for _, d := range details {
m, ok := d.(map[string]any)
if !ok {
continue
}
if v, _ := m["value"].(string); v != "" {
values = append(values, v)
}
}
return strings.Join(values, "; ")
}
// extractMissingScopes walks resp["error"]["permission_violations"][].subject. // extractMissingScopes walks resp["error"]["permission_violations"][].subject.
// Returns nil when the structure is absent. // Returns nil when the structure is absent.
func extractMissingScopes(resp map[string]any) []string { func extractMissingScopes(resp map[string]any) []string {

View File

@@ -220,111 +220,6 @@ func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
} }
} }
// TestBuildAPIError_DetailsLiftedToHintOnAPIArm pins that BuildAPIError lifts
// resp.error.details[].value into Problem.Hint when the response routes to the
// catch-all CategoryAPI arm. The real Lark shape (verified for code 190014) is
// {"error":{"details":[{"value":"end_time should be later than start_time"}]}}
// — only a human-readable reason string, no machine-readable field name. It is
// lifted into Hint (sanctioned free-text recovery prompt) rather than fabricated
// structured params.
func TestBuildAPIError_DetailsLiftedToHintOnAPIArm(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "end_time should be later than start_time"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if !strings.Contains(p.Hint, "end_time should be later than start_time") {
t.Errorf("Hint = %q, want it to contain the server detail value", p.Hint)
}
}
// TestBuildAPIError_MultipleDetailsJoinedIntoHint pins that multiple non-empty
// detail values are joined with "; " into a single Hint, and empty values are
// skipped.
func TestBuildAPIError_MultipleDetailsJoinedIntoHint(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
map[string]any{"value": "first reason"},
map[string]any{"value": ""},
map[string]any{"value": "second reason"},
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "first reason; second reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "first reason; second reason")
}
}
// TestBuildAPIError_DetailsSkipsNonMapEntries pins that malformed entries in
// the details array (not a JSON object) are skipped rather than panicking, and
// well-formed siblings still surface in the Hint.
func TestBuildAPIError_DetailsSkipsNonMapEntries(t *testing.T) {
resp := map[string]any{
"code": 190014,
"msg": "invalid params",
"error": map[string]any{
"details": []any{
"i am a bare string, not an object",
map[string]any{"value": "the real reason"},
42,
},
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
if p.Hint != "the real reason" {
t.Errorf("Hint = %q, want %q", p.Hint, "the real reason")
}
}
// TestBuildAPIError_DetailsMalformedShapesNoHint pins that a missing error
// block, a non-array details field, and an empty details array all leave the
// Hint untouched (no lifted detail) instead of erroring.
func TestBuildAPIError_DetailsMalformedShapesNoHint(t *testing.T) {
cases := []struct {
name string
resp map[string]any
}{
{"no error block", map[string]any{"code": 190014, "msg": "invalid params"}},
{"details not array", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": "nope"}}},
{"empty details", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{}}}},
{"detail values all empty", map[string]any{"code": 190014, "msg": "invalid params", "error": map[string]any{"details": []any{map[string]any{"value": ""}}}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := errclass.BuildAPIError(tc.resp, errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatal("ProblemOf returned !ok")
}
// With no liftable detail, the Hint must not echo a server detail.
if strings.Contains(p.Hint, "nope") {
t.Errorf("Hint should not lift a non-array details field, got %q", p.Hint)
}
})
}
}
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty // TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
// when the upstream response omits it — wire envelope must omit the field. // when the upstream response omits it — wire envelope must omit the field.
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) { func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {

View File

@@ -1,16 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// calendarCodeMeta holds calendar-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var calendarCodeMeta = map[int]CodeMeta{
190014: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid params (carries a field-level detail lifted into Hint)
}
func init() { mergeCodeMeta(calendarCodeMeta, "calendar") }

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_CalendarCodes pins each calendar-service code registered
// via the codemeta_calendar.go init() merge to its expected
// Category/Subtype/Retryable.
func TestLookupCodeMeta_CalendarCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 190014: calendar "invalid params" with a field-level detail
// (error.details[].value) lifted into Hint by BuildAPIError.
{190014, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

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

@@ -1,18 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// minutesCodeMeta holds minutes-service Lark code → CodeMeta mappings.
// Only codes whose meaning is stable across minutes endpoints are registered;
// endpoint-specific codes fall back to CategoryAPI via BuildAPIError.
// Command-specific messages, hints, and subtypes are layered on top via
// per-command enrichment.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var minutesCodeMeta = map[int]CodeMeta{
2091005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller lacks edit/read permission for the minute
}
func init() { mergeCodeMeta(minutesCodeMeta, "minutes") }

View File

@@ -70,12 +70,6 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
} }
} }
func TestLookupCodeMeta_MinutesEndpointSpecificCode_NotGlobal(t *testing.T) {
if got, ok := LookupCodeMeta(2091001); ok {
t.Fatalf("LookupCodeMeta(2091001) = %+v, want unregistered; minutes endpoints use this code for different failures", got)
}
}
func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) { func TestLookupCodeMeta_RetryableAuthCode(t *testing.T) {
got, ok := LookupCodeMeta(20050) got, ok := LookupCodeMeta(20050)
if !ok { if !ok {

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// vcCodeMeta holds vc-service Lark code → CodeMeta mappings.
// Only codes whose meaning is verifiable from repo evidence are registered;
// ambiguous codes (e.g. 124002 "recording still generating", which has no
// precise taxonomy fit) fall back to CategoryAPI via BuildAPIError and rely on
// per-command enrichment for a retry hint.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var vcCodeMeta = map[int]CodeMeta{
121004: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // meeting has no minute file
121005: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // caller is not a participant / lacks view permission
}
func init() { mergeCodeMeta(vcCodeMeta, "vc") }

View File

@@ -231,9 +231,14 @@ func TestLoadAutoApproveSet(t *testing.T) {
t.Fatal("expected non-empty auto-approve set") t.Fatal("expected non-empty auto-approve set")
} }
// From scope_priorities.json recommend=="true" // From scope_overrides.json allow list
if !aaSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in auto-approve set (from allow list)")
}
// Verify allow list entries are present
if !aaSet["sheets:spreadsheet:read"] { if !aaSet["sheets:spreadsheet:read"] {
t.Error("expected sheets:spreadsheet:read in auto-approve set (recommend=true in priorities)") t.Error("expected sheets:spreadsheet:read in auto-approve set (from allow list)")
} }
t.Logf("Auto-approve set has %d scopes", len(aaSet)) t.Logf("Auto-approve set has %d scopes", len(aaSet))
@@ -252,10 +257,16 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) { func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow() allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty: if len(allowSet) == 0 {
// no scopes are special-cased into the auto-approve set anymore. t.Fatal("expected non-empty override allow set")
if len(allowSet) != 0 { }
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
// Known entries from scope_overrides.json
if !allowSet["calendar:calendar.event:create"] {
t.Error("expected calendar:calendar.event:create in allow set")
}
if !allowSet["mail:event"] {
t.Error("expected mail:event in allow set")
} }
} }
@@ -266,9 +277,9 @@ func TestLoadOverrideAutoApproveDeny(t *testing.T) {
} }
func TestIsAutoApproveScope(t *testing.T) { func TestIsAutoApproveScope(t *testing.T) {
// Known auto-approve scope (recommend=true in scope_priorities.json) // Known auto-approve scope (in allow list)
if !IsAutoApproveScope("sheets:spreadsheet:read") { if !IsAutoApproveScope("calendar:calendar.event:create") {
t.Error("expected sheets:spreadsheet:read to be auto-approve") t.Error("expected calendar:calendar.event:create to be auto-approve")
} }
// Completely unknown scope // Completely unknown scope
@@ -279,8 +290,9 @@ func TestIsAutoApproveScope(t *testing.T) {
func TestFilterAutoApproveScopes(t *testing.T) { func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{ scopes := []string{
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities) "calendar:calendar.event:create", // auto-approve (in allow list)
"zzz:unknown:scope", // not in auto-approve "zzz:unknown:scope", // not in auto-approve
"sheets:spreadsheet:read", // auto-approve (in allow list)
} }
result := FilterAutoApproveScopes(scopes) result := FilterAutoApproveScopes(scopes)
@@ -288,10 +300,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
t.Fatal("expected at least 1 auto-approve scope in result") t.Fatal("expected at least 1 auto-approve scope in result")
} }
// Check that sheets:spreadsheet:read is included // Check that calendar:calendar.event:create is included
found := false found := false
for _, s := range result { for _, s := range result {
if s == "sheets:spreadsheet:read" { if s == "calendar:calendar.event:create" {
found = true found = true
} }
// Ensure unknown scopes are not included // Ensure unknown scopes are not included
@@ -300,7 +312,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
} }
} }
if !found { if !found {
t.Error("expected sheets:spreadsheet:read in result") t.Error("expected calendar:calendar.event:create in result")
} }
} }

View File

@@ -12,7 +12,25 @@
"vc:meeting.meetingevent:read": 75 "vc:meeting.meetingevent:read": 75
}, },
"recommend": { "recommend": {
"allow": [], "allow": [
"calendar:calendar.event:create",
"calendar:calendar.event:delete",
"calendar:calendar.event:read",
"calendar:calendar.event:update",
"calendar:calendar.free_busy:read",
"calendar:calendar:create",
"calendar:calendar:delete",
"calendar:calendar:read",
"calendar:calendar:update",
"contact:user.basic_profile:readonly",
"mail:event",
"mail:user_mailbox.mail_contact:read",
"mail:user_mailbox.mail_contact:write",
"mail:user_mailbox.message.address:read",
"mail:user_mailbox.message.body:read",
"mail:user_mailbox.message.subject:read",
"mail:user_mailbox.message:readonly"
],
"deny": [ "deny": [
"im:chat", "im:chat",
"im:message.send_as_user" "im:message.send_as_user"

View File

@@ -10,13 +10,10 @@ import (
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"io"
"net/http"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -40,15 +37,9 @@ const (
) )
const ( const (
npmInstallTimeout = 10 * time.Minute npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute skillsUpdateTimeout = 2 * time.Minute
skillsIndexMaxBodySize = 1 << 20 verifyTimeout = 10 * time.Second
verifyTimeout = 10 * time.Second
)
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
) )
// DetectResult holds installation detection results. // DetectResult holds installation detection results.
@@ -92,7 +83,6 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct { type Updater struct {
DetectOverride func() DetectResult DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult NpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool RestoreAvailableOverride func() bool
@@ -163,53 +153,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r return r
} }
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride()
}
r := &NpmResult{}
ctx, cancel := context.WithTimeout(context.Background(), skillsIndexFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, officialSkillsIndexURL, nil)
if err != nil {
r.Err = err
return r
}
client := transport.NewHTTPClient(0)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return fmt.Errorf("official skills index redirected to non-HTTPS URL: %s", req.URL.Redacted())
}
return nil
}
resp, err := client.Do(req)
if err != nil {
r.Err = err
return r
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
r.Err = fmt.Errorf("official skills index returned HTTP %d", resp.StatusCode)
return r
}
limited := io.LimitReader(resp.Body, skillsIndexMaxBodySize+1)
if _, err := io.Copy(&r.Stdout, limited); err != nil {
r.Err = err
return r
}
if r.Stdout.Len() > skillsIndexMaxBodySize {
r.Stdout.Reset()
r.Err = fmt.Errorf("official skills index exceeds %d bytes", skillsIndexMaxBodySize)
return r
}
return r
}
func (u *Updater) ListOfficialSkills() *NpmResult { func (u *Updater) ListOfficialSkills() *NpmResult {
r := u.runSkillsListOfficial("https://open.feishu.cn") r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil { if r.Err != nil {
@@ -222,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal() return u.runSkillsListGlobal()
} }
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult { func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList) r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil { if r.Err != nil {

View File

@@ -4,18 +4,12 @@
package selfupdate package selfupdate
import ( import (
"context"
"errors"
"fmt" "fmt"
"net"
"net/http"
"net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"testing" "testing"
"time"
"github.com/larksuite/cli/internal/vfs" "github.com/larksuite/cli/internal/vfs"
) )
@@ -194,13 +188,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
}, },
want: "-y skills ls -g", 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", name: "install skill primary",
run: func(u *Updater) *NpmResult { run: func(u *Updater) *NpmResult {
@@ -238,113 +225,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
} }
} }
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if got := result.Stdout.String(); !strings.Contains(got, "lark-calendar") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want skill JSON", got)
}
}
func TestListOfficialSkillsIndexHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "HTTP 404") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want HTTP 404", result.Err)
}
}
func TestListOfficialSkillsIndexBodyTooLarge(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, strings.Repeat("x", skillsIndexMaxBodySize+1))
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "exceeds") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want exceeds", result.Err)
}
if result.Stdout.Len() != 0 {
t.Fatalf("ListOfficialSkillsIndex() stdout len = %d, want 0", result.Stdout.Len())
}
}
func TestListOfficialSkillsIndexTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(200 * time.Millisecond)
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
oldTimeout := skillsIndexFetchTimeout
officialSkillsIndexURL = server.URL
skillsIndexFetchTimeout = 50 * time.Millisecond
t.Cleanup(func() {
officialSkillsIndexURL = oldURL
skillsIndexFetchTimeout = oldTimeout
})
result := New().ListOfficialSkillsIndex()
var netErr net.Error
if result.Err == nil || (!errors.Is(result.Err, context.DeadlineExceeded) && !(errors.As(result.Err, &netErr) && netErr.Timeout())) {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want timeout error", result.Err)
}
}
func TestListOfficialSkillsIndexRejectsNonHTTPSRedirect(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "http://example.com/skills.json", http.StatusFound)
}))
defer server.Close()
oldURL := officialSkillsIndexURL
officialSkillsIndexURL = server.URL
t.Cleanup(func() { officialSkillsIndexURL = oldURL })
result := New().ListOfficialSkillsIndex()
if result.Err == nil || !strings.Contains(result.Err.Error(), "non-HTTPS") {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want non-HTTPS redirect", result.Err)
}
}
func TestListOfficialSkillsIndexUsesOverride(t *testing.T) {
result := (&Updater{SkillsIndexFetchOverride: func() *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"override-skill"}]}`)
return r
}}).ListOfficialSkillsIndex()
if result.Err != nil {
t.Fatalf("ListOfficialSkillsIndex() err = %v, want nil", result.Err)
}
if !strings.Contains(result.Stdout.String(), "override-skill") {
t.Fatalf("ListOfficialSkillsIndex() stdout = %q, want override result", result.Stdout.String())
}
}
func TestListOfficialSkillsFallsBack(t *testing.T) { func TestListOfficialSkillsFallsBack(t *testing.T) {
called := []string{} called := []string{}
updater := &Updater{ updater := &Updater{

View File

@@ -1,209 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skillcontent reads embedded skill content from an injected fs.FS
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
package skillcontent
import (
"io/fs"
"path"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"gopkg.in/yaml.v3"
)
type Reader struct {
fsys fs.FS
}
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
type SkillInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
// fed straight back into `read`.
type DirEntry struct {
Path string `json:"path"`
IsDir bool `json:"is_dir"`
}
func (r *Reader) List() ([]SkillInfo, error) {
entries, err := fs.ReadDir(r.fsys, ".")
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
}
out := make([]SkillInfo, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
// Skip dirs that aren't real skills (no SKILL.md).
if info, ok := r.skillInfo(e.Name()); ok {
out = append(out, info)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
if err != nil {
return SkillInfo{}, false
}
desc, version, metadata := parseFrontmatter(data)
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
}
// ListPath lists one directory layer (no recursion) under "<name>" or
// "<name>/<sub>", returning the entries and the cleaned path listed.
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
name, sub := SplitArg(arg)
if err := r.ensureSkill(name); err != nil {
return nil, "", err
}
dir := name
if sub != "" {
cleaned, err := cleanSubPath(sub)
if err != nil {
return nil, "", err
}
dir = name + "/" + cleaned
info, err := fs.Stat(r.fsys, dir)
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"path %q not found in skill %q", sub, name).
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
}
if !info.IsDir() {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
}
}
entries, err := fs.ReadDir(r.fsys, dir)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
out := make([]DirEntry, 0, len(entries))
for _, e := range entries {
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out, dir, nil
}
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
// separator is a bare skill name (rest "").
func SplitArg(arg string) (name, rest string) {
name, rest, _ = strings.Cut(arg, "/")
return name, rest
}
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
// unparseable frontmatter yields ("", "", nil), never an error.
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
lines := strings.Split(string(skillMD), "\n")
if strings.TrimRight(lines[0], "\r") != "---" {
return "", "", nil
}
block := make([]string, 0, len(lines))
closed := false
for _, ln := range lines[1:] {
if strings.TrimRight(ln, "\r") == "---" {
closed = true
break
}
block = append(block, ln)
}
if !closed {
return "", "", nil
}
var fm struct {
Description string `yaml:"description"`
Version string `yaml:"version"`
Metadata map[string]any `yaml:"metadata"`
}
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
return "", "", nil
}
return fm.Description, fm.Version, fm.Metadata
}
func (r *Reader) ReadSkill(name string) ([]byte, error) {
if err := r.ensureSkill(name); err != nil {
return nil, err
}
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
return data, nil
}
func (r *Reader) ensureSkill(name string) error {
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return unknownSkill(name)
}
info, err := fs.Stat(r.fsys, name)
if err != nil || !info.IsDir() {
return unknownSkill(name)
}
return nil
}
func unknownSkill(name string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
WithHint("run 'lark-cli skills list' to see available skills")
}
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
func cleanSubPath(relpath string) (string, error) {
cleaned := path.Clean(relpath)
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
// survives; reject it explicitly alongside "../".
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid path %q: must be a relative path without '..'", relpath)
}
return cleaned, nil
}
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
if err := r.ensureSkill(name); err != nil {
return nil, "", err
}
cleaned, err := cleanSubPath(relpath)
if err != nil {
return nil, "", err
}
full := name + "/" + cleaned
info, err := fs.Stat(r.fsys, full)
if err != nil {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"reference %q not found in skill %q", relpath, name).
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
}
if info.IsDir() {
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"reference %q is a directory, not a file", relpath)
}
data, err := fs.ReadFile(r.fsys, full)
if err != nil {
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
"failed to read embedded skill content: %v", err)
}
return data, cleaned, nil
}

View File

@@ -1,290 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillcontent
import (
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/errs"
)
func testFS() fstest.MapFS {
return fstest.MapFS{
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
"lark-calendar/references/create.md": {Data: []byte("# Create")},
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
"lark-im/references/send.md": {Data: []byte("# Send")},
}
}
func TestList(t *testing.T) {
r := New(testFS())
skills, err := r.List()
if err != nil {
t.Fatalf("List() error: %v", err)
}
if len(skills) != 2 {
t.Fatalf("got %d skills, want 2", len(skills))
}
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
t.Fatalf("skills not sorted by name: %v", skills)
}
if skills[0].Description != "Calendar skill" {
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
}
// version is the frontmatter `version:` field, passed through for drift checks.
if skills[0].Version != "1.0.0" {
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
}
// metadata is the frontmatter `metadata:` block, passed through verbatim.
if skills[0].Metadata == nil {
t.Fatal("expected metadata for lark-calendar")
}
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
}
// No frontmatter → empty description and nil metadata (omitted from JSON).
if skills[1].Description != "" {
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
}
if skills[1].Metadata != nil {
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
}
if skills[1].Version != "" {
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
}
}
func TestListPath(t *testing.T) {
r := New(testFS())
// Skill root: direct children only (one layer), each path skill-prefixed.
entries, listed, err := r.ListPath("lark-calendar")
if err != nil {
t.Fatalf("ListPath root error: %v", err)
}
if listed != "lark-calendar" {
t.Errorf("listed path: got %q", listed)
}
want := map[string]bool{ // path → isDir
"lark-calendar/SKILL.md": false,
"lark-calendar/references": true,
"lark-calendar/assets": true,
}
if len(entries) != len(want) {
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
}
for _, e := range entries {
isDir, ok := want[e.Path]
if !ok {
t.Errorf("unexpected entry %q", e.Path)
continue
}
if e.IsDir != isDir {
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
}
}
// Entries are sorted by path.
if entries[0].Path != "lark-calendar/SKILL.md" {
t.Errorf("entries not sorted: %v", entries)
}
// Subdirectory: one layer under <name>/<subpath>.
subEntries, subListed, err := r.ListPath("lark-calendar/references")
if err != nil {
t.Fatalf("ListPath subdir error: %v", err)
}
if subListed != "lark-calendar/references" {
t.Errorf("listed subpath: got %q", subListed)
}
if len(subEntries) != 2 ||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
subEntries[1].Path != "lark-calendar/references/create.md" {
t.Errorf("subdir entries: got %v", subEntries)
}
// Unknown skill → typed validation error.
if _, _, err := r.ListPath("no-such-skill"); err == nil {
t.Error("expected error for unknown skill")
} else {
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Errorf("expected *errs.ValidationError, got %T", err)
}
}
// Path that points at a file (not a dir) → validation error.
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
t.Error("expected error listing a file")
} else if !strings.Contains(err.Error(), "is a file") {
t.Errorf("message: got %q", err.Error())
}
// Nonexistent subpath → validation error.
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
t.Error("expected not-found error")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("message: got %q", err.Error())
}
// Traversal in the subpath is rejected, no listing leaked.
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
entries, _, err := r.ListPath(bad)
if err == nil {
t.Errorf("expected rejection for %q", bad)
}
if entries != nil {
t.Errorf("entries leaked for %q: %v", bad, entries)
}
}
}
func TestReadSkill(t *testing.T) {
r := New(testFS())
data, err := r.ReadSkill("lark-calendar")
if err != nil {
t.Fatalf("ReadSkill error: %v", err)
}
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
t.Errorf("unexpected content: %q", string(data))
}
_, err = r.ReadSkill("no-such-skill")
if err == nil {
t.Fatal("expected error for unknown skill")
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
t.Errorf("message: got %q", verr.Message)
}
if _, err := r.ReadSkill("../etc"); err == nil {
t.Error("expected error for name with separator")
}
}
func TestReadReference(t *testing.T) {
r := New(testFS())
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
if err != nil {
t.Fatalf("ReadReference error: %v", err)
}
if string(data) != "# Agenda" {
t.Errorf("content: got %q", string(data))
}
if cleaned != "references/agenda.md" {
t.Errorf("cleaned path: got %q", cleaned)
}
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
t.Error("expected not-found error")
} else if !strings.Contains(err.Error(), "not found") {
t.Errorf("message: got %q", err.Error())
}
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
t.Error("expected directory error")
} else if !strings.Contains(err.Error(), "is a directory") {
t.Errorf("message: got %q", err.Error())
}
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
data, _, err := r.ReadReference("lark-calendar", bad)
if err == nil {
t.Errorf("expected rejection for %q", bad)
}
if data != nil {
t.Errorf("content leaked for %q: %q", bad, string(data))
}
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Errorf("expected validation error for %q, got %T", bad, err)
}
}
}
func TestParseFrontmatter(t *testing.T) {
cases := []struct {
name string
input string
wantDesc string
wantVer string
wantHasMeta bool
}{
{
name: "description, version and metadata",
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
wantDesc: "My skill",
wantVer: "2.1.0",
wantHasMeta: true,
},
{
name: "description only, no metadata",
input: "---\ndescription: Plain\n---\nbody\n",
wantDesc: "Plain",
},
{
name: "no frontmatter",
input: "no frontmatter here\n",
},
{
name: "unclosed frontmatter",
input: "---\ndescription: Never closed\n",
},
{
name: "malformed YAML inside frontmatter",
input: "---\n: bad: yaml: [\n---\nbody\n",
},
{
name: "CRLF line endings",
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
wantDesc: "CRLF skill",
wantHasMeta: true,
},
{
name: "empty input",
input: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
desc, ver, meta := parseFrontmatter([]byte(tc.input))
if desc != tc.wantDesc {
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
}
if ver != tc.wantVer {
t.Errorf("version = %q, want %q", ver, tc.wantVer)
}
if (meta != nil) != tc.wantHasMeta {
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
}
})
}
}
func TestReadSkillMissingFile(t *testing.T) {
// Use a separate MapFS so testFS() (and TestList) are unaffected.
emptyFS := fstest.MapFS{
"lark-empty/references/x.md": {Data: []byte("# X")},
}
r := New(emptyFS)
_, err := r.ReadSkill("lark-empty")
if err == nil {
t.Fatal("expected error when SKILL.md is absent")
}
var ierr *errs.InternalError
if !errors.As(err, &ierr) {
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
}
}

View File

@@ -4,7 +4,6 @@
package skillscheck package skillscheck
import ( import (
"encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"sort" "sort"
@@ -58,52 +57,6 @@ func ParseSkillsList(text string) []string {
return nil 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)
}
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
type officialSkill struct {
Name string `json:"name"`
}
type officialIndex struct {
Skills []officialSkill `json:"skills"`
}
var index officialIndex
if err := json.Unmarshal([]byte(text), &index); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, skill := range index.Skills {
candidate := strings.TrimSpace(skill.Name)
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
return sortedKeys(seen), nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g" // parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string { func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
@@ -124,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
continue continue
} }
if strings.HasPrefix(trimmed, "Agents:") { // Skip indented lines (Agents: ...)
continue if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
}
if isGlobalSkillsSectionHeader(trimmed) {
continue continue
} }
@@ -141,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0] candidate := parts[0]
// Validate and add // Validate and add
if candidate == "" || !skillNamePattern.MatchString(candidate) { if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
continue continue
} }
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true seen[candidate] = true
} }
return sortedKeys(seen) 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" // parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string { func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
@@ -184,7 +131,8 @@ func parseOfficialSkillsList(lines []string) []string {
if len(parts) > 0 { if len(parts) > 0 {
candidate := parts[0] candidate := parts[0]
if skillNamePattern.MatchString(candidate) { // Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
seen[candidate] = true seen[candidate] = true
} }
} }
@@ -246,9 +194,7 @@ func PlanSync(input SyncInput) SyncPlan {
} }
type SkillsRunner interface { type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult InstallAllSkills() *selfupdate.NpmResult
@@ -282,15 +228,21 @@ func SyncSkills(opts SyncOptions) *SyncResult {
} }
// --- Step 1: List official skills --- // --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner) officialResult := opts.Runner.ListOfficialSkills()
if !ok { if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, reason, nil) return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
} }
// --- Step 2: List local (installed) skills --- // --- Step 2: List local (installed) skills ---
local, ok := listLocalSkills(opts.Runner) local := []string{}
if !ok { localResult := opts.Runner.ListGlobalSkills()
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official) if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
} }
// --- Step 3: Read previous state --- // --- Step 3: Read previous state ---
@@ -346,58 +298,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result return result
} }
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
indexResult := runner.ListOfficialSkillsIndex()
if indexResult == nil || indexResult.Err != nil {
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
} else {
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
if err != nil {
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
} else if len(official) > 0 {
return official, "", true
} else {
reasons = append(reasons, "official skills index contains no skills")
}
}
officialResult := runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
return nil, strings.Join(reasons, "; "), false
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) > 0 {
return official, "", true
}
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
} else {
reasons = append(reasons, "official skills list returned no skills")
}
return nil, strings.Join(reasons, "; "), false
}
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) // 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 // 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 // subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -30,19 +30,6 @@ lark-cli-harness:dev@0.1.0
} }
} }
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
input := `Available Skills
│ lark-calendar
│ official-shared
│ bad/name
`
got := ParseSkillsList(input)
want := []string{"lark-calendar", "official-shared"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) { func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills input := `Global Skills
@@ -80,86 +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 TestParseOfficialSkillsIndexJSON(t *testing.T) {
input := `{
"skills": [
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
{"name":" lark-base ","description":"Base","files":[]},
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
{"name":"","description":"empty","files":["SKILL.md"]}
]
}`
got, err := ParseOfficialSkillsIndexJSON(input)
if err != nil {
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
}
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
}
}
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`[{"name":"lark-calendar"}]`,
`{"name":"lark-calendar"}`,
`{"skills":[]}`,
`{"skills":[{"name":"bad skill"}]}`,
} {
got, err := ParseOfficialSkillsIndexJSON(input)
if err == nil && len(got) != 0 {
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) { func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}} previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{ got := PlanSync(SyncInput{
@@ -206,22 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
} }
type fakeSkillsRunner struct { type fakeSkillsRunner struct {
officialIndexOut string officialOut string
officialOut string globalOut string
globalJSONOut string officialErr error
globalOut string globalErr error
officialIndexErr error installErr error
officialErr error installAllErr error
globalJSONErr error installed [][]string
globalErr error installedAll int
installErr error
installAllErr error
installed [][]string
installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
} }
func officialSkillsOutput(names ...string) string { func officialSkillsOutput(names ...string) string {
@@ -235,19 +134,6 @@ func officialSkillsOutput(names ...string) string {
return b.String() return b.String()
} }
func officialSkillsIndexOutput(names ...string) string {
var b strings.Builder
b.WriteString(`{"skills":[`)
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
}
b.WriteString(`]}`)
return b.String()
}
func globalSkillsOutput(names ...string) string { func globalSkillsOutput(names ...string) string {
var b strings.Builder var b strings.Builder
b.WriteString("Global Skills\n\n") b.WriteString("Global Skills\n\n")
@@ -260,45 +146,14 @@ func globalSkillsOutput(names ...string) string {
return b.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) ListOfficialSkillsIndex() *selfupdate.NpmResult {
f.listedIndex++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialIndexOut)
r.Err = f.officialIndexErr
return r
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult { func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
f.listedOfficial++
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut) r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr r.Err = f.officialErr
return r 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 { func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut) r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr r.Err = f.globalErr
@@ -331,10 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
} }
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"), globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
} }
result := SyncSkills(SyncOptions{ result := SyncSkills(SyncOptions{
Version: "1.0.33", Version: "1.0.33",
@@ -346,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err) t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
} }
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"}) 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() state, readable, err := ReadState()
if err != nil || !readable { if err != nil || !readable {
@@ -366,119 +213,12 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
} }
} }
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-should-not-be-used"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
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)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
if runner.listedIndex != 1 {
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
}
if runner.listedOfficial != 0 {
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
}
}
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
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)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
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)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
}
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
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 runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
}
}
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
installAllErr: nil,
}
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 runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) { func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"), officialErr: fmt.Errorf("list failed"),
officialErr: fmt.Errorf("list failed"), installAllErr: nil,
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -506,9 +246,8 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"), officialErr: fmt.Errorf("list failed"),
officialErr: fmt.Errorf("list failed"), installAllErr: fmt.Errorf("full install failed"),
installAllErr: fmt.Errorf("full install failed"),
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -523,76 +262,47 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
} }
} }
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) { func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalErr: fmt.Errorf("global list failed"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil { 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" { if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action) t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
} }
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 { assertStrings(t, result.SkippedDeleted, []string{})
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText) }
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 { if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll) t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
} }
} if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
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{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
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)
} }
} }
@@ -608,10 +318,9 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
} }
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput(),
globalOut: globalSkillsOutput(), installAllErr: nil,
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -634,12 +343,10 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), installErr: fmt.Errorf("incremental boom"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), installAllErr: nil,
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -667,12 +374,10 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), installErr: fmt.Errorf("incremental boom"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), installAllErr: fmt.Errorf("full install boom"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -701,9 +406,8 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"), officialOut: "Some unrecognized output format\n",
officialOut: "Some unrecognized output format\n", installAllErr: nil,
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -719,9 +423,8 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"), officialOut: "Some unrecognized output format\n",
officialOut: "Some unrecognized output format\n", installAllErr: fmt.Errorf("full install failed"),
installAllErr: fmt.Errorf("full install failed"),
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -744,9 +447,8 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"), officialOut: "Some unrecognized output format\n",
officialOut: "Some unrecognized output format\n", installAllErr: nil,
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -770,12 +472,10 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), installErr: fmt.Errorf("incremental boom"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), installAllErr: nil,
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -796,12 +496,10 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"), installErr: fmt.Errorf("incremental boom"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), installAllErr: nil,
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -821,9 +519,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"), officialErr: fmt.Errorf("list failed"),
officialErr: fmt.Errorf("list failed"), installAllErr: nil,
installAllErr: nil,
} }
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -840,10 +537,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
} }
runner2 := &fakeSkillsRunner{ runner2 := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
} }
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now}) result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" { 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,146 +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/base/",
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
}
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

@@ -16,16 +16,7 @@ import (
// call sites must return a typed errs.* error instead. Future domains opt in by // call sites must return a typed errs.* error instead. Future domains opt in by
// appending their path prefix here. // appending their path prefix here.
var migratedEnvelopePaths = []string{ var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"shortcuts/drive/", "shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
} }
// legacyOutputImportPath is the import path of the package that declares the // legacyOutputImportPath is the import path of the package that declares the

View File

@@ -18,7 +18,7 @@ import (
// forbidigo's errs-typed-only ban does not see them because they are method // forbidigo's errs-typed-only ban does not see them because they are method
// calls, not output.Err* identifiers — this AST rule covers that gap. // calls, not output.Err* identifiers — this AST rule covers that gap.
// //
// Migrated code must call the domain's typed API wrapper or use // Migrated code must call a typed API wrapper (e.g. drive's driveCallAPI) or use
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into // runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors. // typed errs.* errors.
// //
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
File: path, File: path,
Line: fset.Position(call.Pos()).Line, Line: fset.Position(call.Pos()).Line,
Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths", Message: "runtime." + name + " emits a legacy output.ExitError api_error envelope and downgrades typed network/auth boundary errors; it is forbidden on migrated paths",
Suggestion: "call the domain's typed API wrapper (for example driveCallAPI or callTaskAPITyped) or runtime.DoAPI + errclass.BuildAPIError " + Suggestion: "call the domain's typed API wrapper (e.g. driveCallAPI) or runtime.DoAPI + errclass.BuildAPIError " +
"so failures classify into typed errs.* errors", "so failures classify into typed errs.* errors",
}) })
} }

View File

@@ -618,35 +618,6 @@ func boom() error {
} }
} }
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
for _, path := range []string{
"shortcuts/okr/okr_image_upload.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_update.go",
} {
t.Run(path, func(t *testing.T) {
src := `package migrated
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral(path, src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
})
}
}
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) { func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
src := `package drive src := `package drive
@@ -691,7 +662,7 @@ func boom() error {
return &output.ExitError{Code: 1} return &output.ExitError{Code: 1}
} }
` `
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src) v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
if len(v) != 0 { if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v) t.Errorf("non-migrated path should pass, got: %+v", v)
} }
@@ -830,26 +801,6 @@ func boom(runtime *common.RuntimeContext) error {
} }
} }
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
src := `package task
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "CallAPI") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) { func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
src := `package drive src := `package drive
@@ -900,14 +851,14 @@ func boom(runtime *common.RuntimeContext) error {
} }
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) { func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package contact src := `package im
func boom(runtime *common.RuntimeContext) error { func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil) _, err := runtime.CallAPI("POST", "/x", nil, nil)
return err return err
} }
` `
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src) v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
if len(v) != 0 { if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v) t.Errorf("non-migrated path must not fire, got: %+v", v)
} }
@@ -926,153 +877,3 @@ func boom(runtime *common.RuntimeContext) error {
t.Errorf("test files must be skipped, got: %+v", v) 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",
"shortcuts/okr/okr_progress_create.go",
"shortcuts/task/task_update.go",
"shortcuts/whiteboard/whiteboard_query.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_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
src := `package calendar
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.RejectDangerousChars("--summary", "x")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Suggestion, "common.RejectDangerousCharsTyped") {
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
}
}
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
src := `package contact
import "github.com/larksuite/cli/shortcuts/common"
func boom() {
common.FlagErrorf("legacy allowed until domain migrates")
}
`
v := CheckNoLegacyCommonHelperCall("shortcuts/contact/contact_get.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, CheckTypedErrorCompleteness(rel, string(src))...)
all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...) all = append(all, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(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. // Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...) all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...) all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

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

View File

@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
return nil return nil
}, },
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "disable advanced permissions failed") return handleRoleResponse(runtime, apiResp.RawBody, "disable advanced permissions failed")
}, },
} }

View File

@@ -30,7 +30,7 @@ var BaseAdvpermEnable = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
return nil return nil
}, },
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "enable advanced permissions failed") return handleRoleResponse(runtime, apiResp.RawBody, "enable advanced permissions failed")
}, },
} }

View File

@@ -196,7 +196,9 @@ func TestBaseAdvpermEnableExecuteAPIError(t *testing.T) {
}, },
}) })
args := []string{"+advperm-enable", "--base-token", "app_x"} args := []string{"+advperm-enable", "--base-token", "app_x"}
assertProblemCode(t, runShortcut(t, BaseAdvpermEnable, args, factory, stdout), 190001, "bad request") if err := runShortcut(t, BaseAdvpermEnable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
} }
func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) { func TestBaseAdvpermDisableExecuteTransportError(t *testing.T) {
@@ -224,5 +226,7 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
}, },
}) })
args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"} args := []string{"+advperm-disable", "--base-token", "app_x", "--yes"}
assertProblemCode(t, runShortcut(t, BaseAdvpermDisable, args, factory, stdout), 190002, "permission denied") if err := runShortcut(t, BaseAdvpermDisable, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
} }

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 baseFlagErrorf("--name must not be blank")
}
if strings.TrimSpace(runtime.Str("type")) == "" {
return baseFlagErrorf("--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 baseFlagErrorf("--before-id and --after-id are mutually exclusive")
}
return nil
}
func validateBaseBlockRename(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("name")) == "" {
return baseFlagErrorf("--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,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber() dec.UseNumber()
if err := dec.Decode(&dsl); err != nil { if err := dec.Decode(&dsl); err != nil {
return baseFlagErrorf("--dsl invalid JSON: %v", err) return common.FlagErrorf("--dsl invalid JSON: %v", err)
} }
_, hasDim := dsl["dimensions"] _, hasDim := dsl["dimensions"]
_, hasMeas := dsl["measures"] _, hasMeas := dsl["measures"]
if !hasDim && !hasMeas { if !hasDim && !hasMeas {
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'") return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
} }
return nil return nil
}, },

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") 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) { func TestDryRunFieldOps(t *testing.T) {
ctx := context.Background() ctx := context.Background()

View File

@@ -4,13 +4,9 @@
package base package base
import ( import (
"errors"
"fmt"
"strings" "strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/util"
) )
@@ -28,198 +24,76 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
// structured ErrAPI, with server-provided message/hint promoted to the top level. // structured ErrAPI, with server-provided message/hint promoted to the top level.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) { func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
if err != nil { if err != nil {
return nil, baseAPIBoundaryError(err, action) return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
} }
resultMap, ok := result.(map[string]interface{}) resultMap, _ := result.(map[string]interface{})
if !ok || resultMap == nil { code, _ := util.ToFloat64(resultMap["code"])
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
}
if _, exists := resultMap["code"]; !exists {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
}
code, numeric := util.ToFloat64(resultMap["code"])
if !numeric {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
}
if code == 0 { if code == 0 {
return resultMap["data"], nil return resultMap["data"], nil
} }
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{}) larkCode := int(code)
} msg := extractDataErrorMessage(resultMap)
if strings.TrimSpace(msg) == "" {
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's msg, _ = resultMap["msg"].(string)
// typed envelope and exists so call sites read as flag rejections.
func baseFlagErrorf(format string, args ...any) error {
return baseValidationErrorf(format, args...)
}
func baseValidationErrorf(format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
if params := flagParams(msg); len(params) > 0 {
err = err.WithParam(params[0].Name).WithParams(params...)
} }
if cause := firstErrorArg(args); cause != nil {
err = err.WithCause(cause) detail := extractErrorDetail(resultMap)
apiErr := output.ErrAPI(larkCode, msg, detail)
hint := extractErrorHint(resultMap)
if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
apiErr.Detail.Hint = hint
} }
return err if apiErr.Detail != nil {
apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
}
return nil, apiErr
} }
func flagParams(msg string) []errs.InvalidParam { func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
reason := msg detailMap, ok := detail.(map[string]interface{})
seen := map[string]bool{} if !ok {
params := []errs.InvalidParam{} return nil
for start := strings.Index(msg, "--"); start >= 0; start = strings.Index(msg, "--") {
end := start + 2
for end < len(msg) {
ch := msg[end]
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
end++
continue
}
break
}
if end > start+2 {
name := msg[start:end]
if !seen[name] {
seen[name] = true
params = append(params, errs.InvalidParam{Name: name, Reason: reason})
}
}
msg = msg[end:]
} }
return params for key, value := range detailMap {
if value == nil {
delete(detailMap, key)
}
}
if len(detailMap) == 0 {
return nil
}
return detailMap
} }
func firstErrorArg(args []any) error { func extractErrorDetail(resultMap map[string]interface{}) interface{} {
for _, arg := range args { if detail, ok := nonNilMapValue(resultMap, "error"); ok {
if err, ok := arg.(error); ok { return detail
return err }
} data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := nonNilMapValue(data, "error"); ok {
return detail
} }
return nil return nil
} }
// baseMissingFileIOError reports a broken runtime wiring: a command that needs func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
// local file access was constructed without a FileIO provider. The user cannot if src == nil {
// fix this by changing flags, so it classifies as internal, not validation. return nil, false
func baseMissingFileIOError(format string, args ...any) error {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
}
func baseInputStatError(err error) error {
if err == nil {
return nil
} }
if errors.Is(err, fileio.ErrPathValidation) { value, ok := src[key]
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err) if !ok {
return nil, false
} }
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err) switch value.(type) {
} case nil:
return nil, false
func baseSaveError(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: default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err) return value, true
} }
} }
func baseAPIBoundaryError(err error, action string) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "%s: %s", action, err).WithCause(err)
}
func baseUploadAttachmentError(filePath string, err error) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = fmt.Sprintf("failed to upload attachment %s: %s", filePath, p.Message)
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "failed to upload attachment %s: %s", filePath, err).WithCause(err)
}
func baseAPIErrorFromResult(resultMap map[string]interface{}, cc errclass.ClassifyContext) error {
if resultMap == nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a malformed response envelope")
}
if msg := extractDataErrorMessage(resultMap); msg != "" {
resultMap["msg"] = msg
}
hint := extractErrorHint(resultMap)
if logID := extractBaseErrorLogID(resultMap); logID != "" {
resultMap["log_id"] = logID
}
err := errclass.BuildAPIError(resultMap, cc)
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok && hint != "" {
p.Hint = hint
}
return err
}
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error {
if _, ok := errs.ProblemOf(err); !ok {
return err
}
result, parseErr := decodeBaseV3Response(body)
if parseErr != nil {
return err
}
enriched := baseAPIErrorFromResult(result, cc)
if enriched == nil {
return err
}
src, _ := errs.ProblemOf(enriched)
dst, _ := errs.ProblemOf(err)
if src != nil && dst != nil {
dst.Message = src.Message
dst.Hint = src.Hint
// A body without log_id must not erase a header-derived LogID
// already carried by err.
if src.LogID != "" {
dst.LogID = src.LogID
}
}
return err
}
func extractBaseErrorLogID(resultMap map[string]interface{}) string {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := resultMap[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok {
for _, key := range []string{"log_id", "logid"} {
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
}
}
}
return ""
}
func extractErrorHint(resultMap map[string]interface{}) string { func extractErrorHint(resultMap map[string]interface{}) string {
if detail, ok := resultMap["error"].(map[string]interface{}); ok { if detail, ok := resultMap["error"].(map[string]interface{}); ok {
if hint := consumeStringField(detail, "hint"); hint != "" { if hint := consumeStringField(detail, "hint"); hint != "" {

View File

@@ -4,15 +4,30 @@
package base package base
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/errclass"
) )
func TestErrorDetailHelpers(t *testing.T) { func TestErrorDetailHelpers(t *testing.T) {
if value, ok := nonNilMapValue(nil, "error"); ok || value != nil {
t.Fatalf("nil map should not return value")
}
if value, ok := nonNilMapValue(map[string]interface{}{"error": nil}, "error"); ok || value != nil {
t.Fatalf("nil entry should not return value")
}
detail := map[string]interface{}{"message": "boom", "hint": "retry later"} detail := map[string]interface{}{"message": "boom", "hint": "retry later"}
if value, ok := nonNilMapValue(map[string]interface{}{"error": detail}, "error"); !ok || value == nil {
t.Fatalf("expected non-nil detail")
}
if got := extractErrorDetail(map[string]interface{}{"error": detail}); got == nil {
t.Fatalf("expected root detail")
}
if got := extractErrorDetail(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got == nil {
t.Fatalf("expected nested detail")
}
if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" { if got := extractErrorHint(map[string]interface{}{"data": map[string]interface{}{"error": detail}}); got != "retry later" {
t.Fatalf("hint=%q", got) t.Fatalf("hint=%q", got)
} }
@@ -38,12 +53,9 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") { if _, err := handleBaseAPIResultAny(result, nil, "set filter"); err == nil || !strings.Contains(err.Error(), "invalid filter") {
t.Fatalf("err=%v", err) t.Fatalf("err=%v", err)
} else { } else {
p, ok := errs.ProblemOf(err) var exitErr *output.ExitError
if !ok || p.Code != 190001 { if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
t.Fatalf("expected typed code 190001, got %T %v", err, err) t.Fatalf("expected structured code 190001, got %v", err)
}
if p.Hint != "check field name" {
t.Fatalf("hint=%q", p.Hint)
} }
} }
if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil { if _, err := handleBaseAPIResult(result, nil, "set filter"); err == nil {
@@ -51,7 +63,7 @@ func TestHandleBaseAPIResultErrorPaths(t *testing.T) {
} }
} }
func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) { func TestHandleBaseAPIResultCleansBaseErrorDetail(t *testing.T) {
result := map[string]interface{}{ result := map[string]interface{}{
"code": 800010407, "code": 800010407,
"msg": "cell value invalid", "msg": "cell value invalid",
@@ -75,27 +87,55 @@ func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
} }
_, err := handleBaseAPIResultAny(result, nil, "API call failed") _, err := handleBaseAPIResultAny(result, nil, "API call failed")
p, ok := errs.ProblemOf(err) var exitErr *output.ExitError
if !ok { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected typed error, got %T %v", err, err) t.Fatalf("expected structured exit error, got %v", err)
} }
if p.Code != 800010407 {
t.Fatalf("code=%d", p.Code) errDetail := exitErr.Detail
if errDetail.Code != 800010407 {
t.Fatalf("code=%d", errDetail.Code)
} }
if p.Message != "The cell value does not match the expected input shape." { if errDetail.Hint != "Provide a number value." {
t.Fatalf("message=%q", p.Message) t.Fatalf("hint=%q", errDetail.Hint)
} }
if p.Hint != "Provide a number value." { detail, _ := errDetail.Detail.(map[string]interface{})
t.Fatalf("hint=%q", p.Hint) if detail == nil {
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
} }
if p.LogID != "20260508160000000000000000000000" { if _, exists := detail["message"]; exists {
t.Fatalf("logID=%q", p.LogID) t.Fatalf("detail should not repeat message: %#v", detail)
}
if _, exists := detail["hint"]; exists {
t.Fatalf("detail should not repeat hint: %#v", detail)
}
if _, exists := detail["docs_url"]; exists {
t.Fatalf("detail should omit nil docs_url: %#v", detail)
}
if detail["level"] != "error" {
t.Fatalf("detail should preserve non-duplicate fields: %#v", detail)
}
if detail["extra_context"] != "future detail field" {
t.Fatalf("detail should pass through unknown non-nil fields: %#v", detail)
}
if detail["path"] != "Amount" || detail["value"] != "abc" {
t.Fatalf("cleaned detail mismatch: %#v", detail)
}
if detail["logid"] != "20260508160000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
}
if retryable, ok := detail["retryable"].(bool); !ok || retryable {
t.Fatalf("retryable=%v", detail["retryable"])
}
table, _ := detail["table"].(map[string]interface{})
if table["id"] != "tbl_1" || table["name"] != "Orders" {
t.Fatalf("table=%#v", detail["table"])
} }
} }
func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) { func TestHandleBaseAPIResultAlwaysRemovesMessageAndHintFromDetail(t *testing.T) {
result := map[string]interface{}{ result := map[string]interface{}{
"code": 99991676, "code": output.LarkErrTokenNoPermission,
"msg": "permission denied", "msg": "permission denied",
"data": map[string]interface{}{ "data": map[string]interface{}{
"error": map[string]interface{}{ "error": map[string]interface{}{
@@ -106,15 +146,15 @@ func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
} }
_, err := handleBaseAPIResultAny(result, nil, "API call failed") _, err := handleBaseAPIResultAny(result, nil, "API call failed")
p, ok := errs.ProblemOf(err) var exitErr *output.ExitError
if !ok { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected typed error, got %T %v", err, err) t.Fatalf("expected structured exit error, got %v", err)
} }
if p.Code != 99991676 { if exitErr.Detail.Message != "Permission denied [99991676]" {
t.Fatalf("code=%d", p.Code) t.Fatalf("message=%q", exitErr.Detail.Message)
} }
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient { if exitErr.Detail.Detail != nil {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype) t.Fatalf("detail should be empty after removing message and hint: %#v", exitErr.Detail.Detail)
} }
} }
@@ -127,91 +167,16 @@ func TestAttachBaseResponseLogIDFromHeader(t *testing.T) {
attachBaseErrorLogID(result, "20260508170000000000000000000000") attachBaseErrorLogID(result, "20260508170000000000000000000000")
_, err := handleBaseAPIResultAny(result, nil, "API call failed") _, err := handleBaseAPIResultAny(result, nil, "API call failed")
p, ok := errs.ProblemOf(err) var exitErr *output.ExitError
if !ok { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected typed error, got %T %v", err, err) t.Fatalf("expected structured exit error, got %v", err)
} }
if p.LogID != "20260508170000000000000000000000" { detail, _ := exitErr.Detail.Detail.(map[string]interface{})
t.Fatalf("logID=%q", p.LogID) if detail["logid"] != "20260508170000000000000000000000" {
} t.Fatalf("logid=%q", detail["logid"])
}
func TestHandleBaseAPIResultRejectsNonNumericCode(t *testing.T) {
for _, code := range []interface{}{"oops", map[string]interface{}{}, nil} {
result := map[string]interface{}{"code": code, "msg": "weird envelope"}
_, err := handleBaseAPIResultAny(result, nil, "list tables")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("code=%#v: expected typed error, got %T %v", code, err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("code=%#v: category/subtype=%s/%s", code, p.Category, p.Subtype)
}
if !strings.Contains(p.Message, "list tables") {
t.Fatalf("code=%#v: message=%q", code, p.Message)
}
}
}
func TestEnrichBaseAPIErrorFromBodyLogIDMerge(t *testing.T) {
t.Run("body without log_id keeps header-derived LogID", func(t *testing.T) {
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
err := enrichBaseAPIErrorFromBody(outer, []byte(`{"code":190001,"msg":"boom"}`), errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if p.Message != "boom" {
t.Fatalf("message=%q", p.Message)
}
if p.LogID != "header-log-id" {
t.Fatalf("logID=%q, want header-log-id", p.LogID)
}
})
t.Run("body log_id overrides header-derived LogID", func(t *testing.T) {
outer := errs.NewAPIError(errs.SubtypeUnknown, "outer failure").WithCode(190001).WithLogID("header-log-id")
body := `{"code":190001,"msg":"boom","data":{"error":{"logid":"body-log-id"}}}`
err := enrichBaseAPIErrorFromBody(outer, []byte(body), errclass.ClassifyContext{})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
}
if p.LogID != "body-log-id" {
t.Fatalf("logID=%q, want body-log-id", p.LogID)
}
})
}
func TestBaseMissingFileIOErrorIsInternal(t *testing.T) {
p, ok := errs.ProblemOf(baseMissingFileIOError("file operations require a FileIO provider"))
if !ok {
t.Fatal("expected typed error")
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeFileIO {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
} }
} }
type assertErr struct{} type assertErr struct{}
func (assertErr) Error() string { return "network timeout" } func (assertErr) Error() string { return "network timeout" }
func assertProblemCode(t *testing.T, err error, code int, messageParts ...string) {
t.Helper()
if err == nil {
t.Fatalf("expected error with code %d", code)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Code != code {
t.Fatalf("code=%d, want %d; err=%v", p.Code, code, err)
}
for _, part := range messageParts {
if !strings.Contains(p.Message, part) {
t.Fatalf("message=%q missing %q", p.Message, part)
}
}
}

View File

@@ -18,7 +18,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -412,167 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
return body 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 TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tests := []struct {
name string
shortcut common.Shortcut
args []string
params []string
}{
{
name: "create blank name",
shortcut: BaseBaseBlockCreate,
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
params: []string{"--name"},
},
{
name: "move conflicting sibling anchors",
shortcut: BaseBaseBlockMove,
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
params: []string{"--before-id", "--after-id"},
},
{
name: "rename blank name",
shortcut: BaseBaseBlockRename,
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
params: []string{"--name"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != tt.params[0] {
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
}
if len(validationErr.Params) != len(tt.params) {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
for i, param := range tt.params {
if validationErr.Params[i].Name != param {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
if validationErr.Params[i].Reason == "" {
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
}
}
})
}
}
func TestBaseHistoryExecute(t *testing.T) { func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
@@ -931,10 +769,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
t.Run("list-http-404", func(t *testing.T) { t.Run("list-http-404", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables", URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404, Status: 404,
RawBody: []byte("404 page not found"), Body: "404 page not found",
Headers: map[string][]string{ Headers: map[string][]string{
"Content-Type": {"text/plain"}, "Content-Type": {"text/plain"},
}, },
@@ -2153,9 +1991,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(err.Error(), "exceeds 2GB limit") { if !strings.Contains(err.Error(), "exceeds 2GB limit") {
t.Fatalf("err=%v", err) t.Fatalf("err=%v", err)
} }
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
t.Fatalf("err=%v should name the offending file", err)
}
}) })
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) { t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
@@ -2325,23 +2160,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
} }
}) })
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "../escape",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
t.Fatalf("err=%v", err)
}
})
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) { t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
@@ -2538,37 +2356,21 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x", "--record-id", "rec_x",
"--output", "downloads", "--output", "downloads",
}, factory, stdout) }, factory, stdout)
if err == nil { if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("err=%v", err) t.Fatalf("err=%v", err)
} }
var partialErr *output.PartialFailureError var exitErr *output.ExitError
if !errors.As(err, &partialErr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected partial failure error, got %T %v", err, err) t.Fatalf("expected structured error, got %T %v", err, err)
} }
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
var envelope map[string]interface{} downloaded, _ := detail["downloaded"].([]map[string]interface{})
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { failed, _ := detail["failed"].([]map[string]interface{})
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String()) if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
} }
if envelope["ok"] != false { if detail["log_id"] != "202605270001" {
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope) t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
data, _ := envelope["data"].(map[string]interface{})
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("message=%q", msg)
}
downloaded, _ := data["downloaded"].([]interface{})
failed, _ := data["failed"].([]interface{})
if len(downloaded) != 1 || len(failed) != 1 {
t.Fatalf("data=%#v", data)
}
downloadedItem, _ := downloaded[0].(map[string]interface{})
failedItem, _ := failed[0].(map[string]interface{})
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
t.Fatalf("data=%#v", data)
}
if data["log_id"] != "202605270001" {
t.Fatalf("data=%#v, want log_id", data)
} }
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil { if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err) t.Fatalf("expected first file to remain: %v", err)

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
var questions []interface{} var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil { if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err) return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
} }
data, err := baseV3Call(runtime, "POST", data, err := baseV3Call(runtime, "POST",

View File

@@ -7,6 +7,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -42,7 +43,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
var questionIds []string var questionIds []string
if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil { if err := json.Unmarshal([]byte(questionIdsJSON), &questionIds); err != nil {
return baseValidationErrorf("--question-ids must be a valid JSON array of strings: %s", err) return output.Errorf(output.ExitValidation, "invalid_json", "--question-ids must be a valid JSON array of strings: %s", err)
} }
_, err := baseV3Call(runtime, "DELETE", _, err := baseV3Call(runtime, "DELETE",

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
var questions []interface{} var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil { if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err) return output.Errorf(output.ExitValidation, "invalid_json", "--questions must be a valid JSON array: %s", err)
} }
data, err := baseV3Call(runtime, "PATCH", data, err := baseV3Call(runtime, "PATCH",

View File

@@ -14,6 +14,7 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -61,31 +62,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
attachments, hasAttachments := raw["attachments"] attachments, hasAttachments := raw["attachments"]
if !hasAttachments && fields == nil { if !hasAttachments && fields == nil {
return baseFlagErrorf("--json must contain at least \"fields\" or \"attachments\"") return common.FlagErrorf("--json must contain at least \"fields\" or \"attachments\"")
} }
if hasAttachments { if hasAttachments {
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要) // 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
if runtime.Str("base-token") == "" { if runtime.Str("base-token") == "" {
return baseFlagErrorf("--base-token is required when --json contains \"attachments\"") return common.FlagErrorf("--base-token is required when --json contains \"attachments\"")
} }
attMap, ok := attachments.(map[string]interface{}) attMap, ok := attachments.(map[string]interface{})
if !ok { if !ok {
return baseFlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays") return common.FlagErrorf("--json.attachments must be a JSON object mapping field names to file path arrays")
} }
for fieldName, value := range attMap { for fieldName, value := range attMap {
paths, ok := value.([]interface{}) paths, ok := value.([]interface{})
if !ok { if !ok {
return baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value) return common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
} }
for i, item := range paths { for i, item := range paths {
if _, ok := item.(string); !ok { if _, ok := item.(string); !ok {
return baseFlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item) return common.FlagErrorf("--json.attachments.%q[%d] must be a file path string, got %T", fieldName, i, item)
} }
} }
if len(paths) == 0 { if len(paths) == 0 {
return baseFlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName) return common.FlagErrorf("--json.attachments.%q must not be empty; remove it or provide at least one file path", fieldName)
} }
} }
} }
@@ -110,21 +111,21 @@ func parseFormSubmitJSON(runtime *common.RuntimeContext) (map[string]interface{}
if attachments, ok := raw["attachments"]; ok { if attachments, ok := raw["attachments"]; ok {
attObj, ok := attachments.(map[string]interface{}) attObj, ok := attachments.(map[string]interface{})
if !ok { if !ok {
return nil, nil, baseFlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`) return nil, nil, common.FlagErrorf(`--json.attachments must be a JSON object mapping field names to file path arrays`)
} }
if len(attObj) > 0 { if len(attObj) > 0 {
attMap = make(map[string][]string, len(attObj)) attMap = make(map[string][]string, len(attObj))
for fieldName, value := range attObj { for fieldName, value := range attObj {
paths, ok := value.([]interface{}) paths, ok := value.([]interface{})
if !ok { if !ok {
return nil, nil, baseFlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value) return nil, nil, common.FlagErrorf("--json.attachments.%q must be a file path array, got %T", fieldName, value)
} }
filePaths := make([]string, 0, len(paths)) filePaths := make([]string, 0, len(paths))
for _, item := range paths { for _, item := range paths {
if s, ok := item.(string); ok { if s, ok := item.(string); ok {
filePaths = append(filePaths, s) filePaths = append(filePaths, s)
} else { } else {
return nil, nil, baseFlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item) return nil, nil, common.FlagErrorf("--json.attachments.%q must contain file path strings only, got %T", fieldName, item)
} }
} }
if len(filePaths) > 0 { if len(filePaths) > 0 {
@@ -194,33 +195,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token") baseToken := runtime.Str("base-token")
fio := runtime.FileIO() fio := runtime.FileIO()
if fio == nil { if fio == nil {
return baseMissingFileIOError("file operations require a FileIO provider (needed for attachments in --json)") return output.ErrValidation("file operations require a FileIO provider (needed for attachments in --json)")
} }
// Step 1: 收集所有唯一路径(跨字段去重) // Step 1: 收集所有唯一路径(跨字段去重)
allPaths := collectUniquePaths(attachmentMap) allPaths := collectUniquePaths(attachmentMap)
if len(allPaths) == 0 { if len(allPaths) == 0 {
return baseFlagErrorf("attachments in --json contains no valid file paths") return common.FlagErrorf("attachments in --json contains no valid file paths")
} }
// Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用 // Step 2: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
sizeMap := make(map[string]int64, len(allPaths)) sizeMap := make(map[string]int64, len(allPaths))
for _, filePath := range allPaths { for _, filePath := range allPaths {
if _, err := validate.SafeInputPath(filePath); err != nil { if _, err := validate.SafeInputPath(filePath); err != nil {
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err) return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
} }
fileInfo, err := fio.Stat(filePath) fileInfo, err := fio.Stat(filePath)
if err != nil { if err != nil {
if errors.Is(err, fileio.ErrPathValidation) { if errors.Is(err, fileio.ErrPathValidation) {
return baseValidationErrorf("unsafe attachment file path: %s: %v", filePath, err) return output.ErrValidation("unsafe attachment file path: %s: %v", filePath, err)
} }
return baseValidationErrorf("attachment file not accessible: %s: %v", filePath, err) return output.ErrValidation("attachment file not accessible: %s: %v", filePath, err)
} }
if fileInfo.Size() > baseAttachmentUploadMaxFileSize { if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath) return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
} }
if !fileInfo.Mode().IsRegular() { if !fileInfo.Mode().IsRegular() {
return baseValidationErrorf("attachment file %s is not a regular file", filePath) return output.ErrValidation("attachment file %s is not a regular file", filePath)
} }
sizeMap[filePath] = fileInfo.Size() sizeMap[filePath] = fileInfo.Size()
} }
@@ -327,7 +328,7 @@ func uploadAttachmentsParallel(runtime *common.RuntimeContext, paths []string, t
func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) { func uploadSingleAttachment(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, target baseAttachmentUploadTarget) (interface{}, error) {
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target) att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
if err != nil { if err != nil {
return nil, baseUploadAttachmentError(filePath, err) return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
} }
return att, nil return att, nil
} }

View File

@@ -5,10 +5,9 @@ package base
import ( import (
"encoding/json" "encoding/json"
"fmt"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -18,14 +17,6 @@ import (
// - Inner: business-level code/message inside the data object // - Inner: business-level code/message inside the data object
// //
// The data field may be a JSON object (actual behavior) or a JSON string (per doc). // The data field may be a JSON object (actual behavior) or a JSON string (per doc).
func handleRoleAPIResponse(runtime *common.RuntimeContext, apiResp *larkcore.ApiResp, action string) error {
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
enriched := enrichBaseAPIErrorFromBody(err, apiResp.RawBody, runtime.APIClassifyContext())
return prefixRoleActionError(enriched, action)
}
return handleRoleResponse(runtime, apiResp.RawBody, action)
}
func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error { func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action string) error {
var resp struct { var resp struct {
Code int `json:"code"` Code int `json:"code"`
@@ -33,17 +24,23 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
} }
if err := json.Unmarshal(rawBody, &resp); err != nil { if err := json.Unmarshal(rawBody, &resp); err != nil {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: failed to parse response: %v", action, err).WithCause(err) return fmt.Errorf("failed to parse response: %v", err)
} }
if resp.Code != 0 { if resp.Code != 0 {
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg} msg := resp.Msg
if len(resp.Data) > 0 { // When outer msg is empty, try to extract error details from data.error.message
var data interface{} if msg == "" && len(resp.Data) > 0 {
if json.Unmarshal(resp.Data, &data) == nil { var errData struct {
result["data"] = data Error struct {
Message string `json:"message"`
Hint string `json:"hint"`
} `json:"error"`
}
if json.Unmarshal(resp.Data, &errData) == nil && errData.Error.Message != "" {
msg = errData.Error.Message
} }
} }
return baseRoleAPIError(runtime, result, action) return output.ErrAPI(resp.Code, fmt.Sprintf("%s: [%d] %s", action, resp.Code, msg), nil)
} }
if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` { if len(resp.Data) == 0 || string(resp.Data) == "null" || string(resp.Data) == `""` {
@@ -78,8 +75,7 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
} }
if codeInt != 0 { if codeInt != 0 {
msg, _ := m["message"].(string) msg, _ := m["message"].(string)
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m} return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
return baseRoleAPIError(runtime, result, action)
} }
// code == 0, extract the inner data if present // code == 0, extract the inner data if present
if innerData, hasInner := m["data"]; hasInner { if innerData, hasInner := m["data"]; hasInner {
@@ -102,20 +98,3 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
runtime.Out(data, nil) runtime.Out(data, nil)
return nil return nil
} }
func baseRoleAPIError(runtime *common.RuntimeContext, result map[string]interface{}, action string) error {
return prefixRoleActionError(baseAPIErrorFromResult(result, runtime.APIClassifyContext()), action)
}
// prefixRoleActionError prepends the failed role action ("create role failed",
// "get role failed", ...) to a typed error's message so both the classified
// outer-response path and the parsed-body path carry the same context.
func prefixRoleActionError(err error, action string) error {
if err == nil {
return nil
}
if p, ok := errs.ProblemOf(err); ok && action != "" {
p.Message = action + ": " + p.Message
}
return err
}

View File

@@ -34,11 +34,11 @@ var BaseRoleCreate = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
var body map[string]any var body map[string]any
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil { if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
return baseFlagErrorf("--json must be valid JSON: %v", err) return common.FlagErrorf("--json must be valid JSON: %v", err)
} }
return nil return nil
}, },
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "create role failed") return handleRoleResponse(runtime, apiResp.RawBody, "create role failed")
}, },
} }

View File

@@ -34,10 +34,10 @@ var BaseRoleDelete = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
if strings.TrimSpace(runtime.Str("role-id")) == "" { if strings.TrimSpace(runtime.Str("role-id")) == "" {
return baseFlagErrorf("--role-id must not be blank") return common.FlagErrorf("--role-id must not be blank")
} }
return nil return nil
}, },
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "delete role failed") return handleRoleResponse(runtime, apiResp.RawBody, "delete role failed")
}, },
} }

View File

@@ -33,10 +33,10 @@ var BaseRoleGet = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
if strings.TrimSpace(runtime.Str("role-id")) == "" { if strings.TrimSpace(runtime.Str("role-id")) == "" {
return baseFlagErrorf("--role-id must not be blank") return common.FlagErrorf("--role-id must not be blank")
} }
return nil return nil
}, },
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "get role failed") return handleRoleResponse(runtime, apiResp.RawBody, "get role failed")
}, },
} }

View File

@@ -32,7 +32,7 @@ var BaseRoleList = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
return nil return nil
}, },
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "list roles failed") return handleRoleResponse(runtime, apiResp.RawBody, "list roles failed")
}, },
} }

View File

@@ -375,7 +375,9 @@ func TestBaseRoleCreateExecuteAPIError(t *testing.T) {
}, },
}) })
args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`} args := []string{"+role-create", "--base-token", "app_x", "--json", `{"role_name":"Bad"}`}
assertProblemCode(t, runShortcut(t, BaseRoleCreate, args, factory, stdout), 190001, "create role failed", "bad request") if err := runShortcut(t, BaseRoleCreate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190001") {
t.Fatalf("err=%v", err)
}
} }
func TestBaseRoleListExecuteTransportError(t *testing.T) { func TestBaseRoleListExecuteTransportError(t *testing.T) {
@@ -403,7 +405,9 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
}, },
}) })
args := []string{"+role-list", "--base-token", "app_x"} args := []string{"+role-list", "--base-token", "app_x"}
assertProblemCode(t, runShortcut(t, BaseRoleList, args, factory, stdout), 190002, "not found") if err := runShortcut(t, BaseRoleList, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190002") {
t.Fatalf("err=%v", err)
}
} }
func TestBaseRoleDeleteExecuteAPIError(t *testing.T) { func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
@@ -417,7 +421,9 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
}, },
}) })
args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"} args := []string{"+role-delete", "--base-token", "app_x", "--role-id", "rol_1", "--yes"}
assertProblemCode(t, runShortcut(t, BaseRoleDelete, args, factory, stdout), 190003, "forbidden") if err := runShortcut(t, BaseRoleDelete, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190003") {
t.Fatalf("err=%v", err)
}
} }
func TestBaseRoleUpdateExecuteAPIError(t *testing.T) { func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
@@ -431,7 +437,9 @@ func TestBaseRoleUpdateExecuteAPIError(t *testing.T) {
}, },
}) })
args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"} args := []string{"+role-update", "--base-token", "app_x", "--role-id", "rol_1", "--json", `{"role_name":"X"}`, "--yes"}
assertProblemCode(t, runShortcut(t, BaseRoleUpdate, args, factory, stdout), 190004, "invalid params") if err := runShortcut(t, BaseRoleUpdate, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "190004") {
t.Fatalf("err=%v", err)
}
} }
func TestBaseRoleGetExecuteBusinessError(t *testing.T) { func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
@@ -449,7 +457,9 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
}, },
}) })
args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"} args := []string{"+role-get", "--base-token", "app_x", "--role-id", "rol_bad"}
assertProblemCode(t, runShortcut(t, BaseRoleGet, args, factory, stdout), 100001, "role not found") if err := runShortcut(t, BaseRoleGet, args, factory, stdout); err == nil || !strings.Contains(err.Error(), "100001") || !strings.Contains(err.Error(), "role not found") {
t.Fatalf("err=%v", err)
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -477,7 +487,9 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("outer error code", func(t *testing.T) { t.Run("outer error code", func(t *testing.T) {
rt := newRoleResponseRuntime(t) rt := newRoleResponseRuntime(t)
assertProblemCode(t, handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"), 999, "outer error") if err := handleRoleResponse(rt, []byte(`{"code":999,"msg":"outer error"}`), "test"); err == nil || !strings.Contains(err.Error(), "999") {
t.Fatalf("err=%v", err)
}
}) })
t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) { t.Run("outer error code with empty msg and data.error.message", func(t *testing.T) {
@@ -562,7 +574,9 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("business code non-zero", func(t *testing.T) { t.Run("business code non-zero", func(t *testing.T) {
rt := newRoleResponseRuntime(t) rt := newRoleResponseRuntime(t)
body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}` body := `{"code":0,"msg":"ok","data":{"code":50001,"message":"permission denied"}}`
assertProblemCode(t, handleRoleResponse(rt, []byte(body), "test"), 50001, "permission denied") if err := handleRoleResponse(rt, []byte(body), "test"); err == nil || !strings.Contains(err.Error(), "50001") {
t.Fatalf("err=%v", err)
}
}) })
t.Run("data is array", func(t *testing.T) { t.Run("data is array", func(t *testing.T) {

View File

@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" { if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank") return common.FlagErrorf("--base-token must not be blank")
} }
if strings.TrimSpace(runtime.Str("role-id")) == "" { if strings.TrimSpace(runtime.Str("role-id")) == "" {
return baseFlagErrorf("--role-id must not be blank") return common.FlagErrorf("--role-id must not be blank")
} }
var body map[string]any var body map[string]any
if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil { if err := json.Unmarshal([]byte(runtime.Str("json")), &body); err != nil {
return baseFlagErrorf("--json must be valid JSON: %v", err) return common.FlagErrorf("--json must be valid JSON: %v", err)
} }
return nil return nil
}, },
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
return err return err
} }
return handleRoleAPIResponse(runtime, apiResp, "update role failed") return handleRoleResponse(runtime, apiResp.RawBody, "update role failed")
}, },
} }

View File

@@ -30,34 +30,34 @@ func baseTableID(runtime *common.RuntimeContext) string {
func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) { func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return "", baseFlagErrorf("--%s cannot be empty", flagName) return "", common.FlagErrorf("--%s cannot be empty", flagName)
} }
if !strings.HasPrefix(raw, "@") { if !strings.HasPrefix(raw, "@") {
return raw, nil return raw, nil
} }
path := strings.TrimSpace(strings.TrimPrefix(raw, "@")) path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" { if path == "" {
return "", baseFlagErrorf("--%s file path cannot be empty after @", flagName) return "", common.FlagErrorf("--%s file path cannot be empty after @", flagName)
} }
if pc.fio == nil { if pc.fio == nil {
return "", baseMissingFileIOError("--%s @file inputs require a FileIO provider", flagName) return "", common.FlagErrorf("--%s @file inputs require a FileIO provider", flagName)
} }
f, err := pc.fio.Open(path) f, err := pc.fio.Open(path)
if err != nil { if err != nil {
var pathErr *fileio.PathValidationError var pathErr *fileio.PathValidationError
if errors.As(err, &pathErr) { if errors.As(err, &pathErr) {
return "", baseFlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err) return "", common.FlagErrorf("--%s invalid JSON file path %q: %v", flagName, path, pathErr.Err)
} }
return "", baseFlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err) return "", common.FlagErrorf("--%s cannot open JSON file %q: %v", flagName, path, err)
} }
defer f.Close() defer f.Close()
data, err := io.ReadAll(f) data, err := io.ReadAll(f)
if err != nil { if err != nil {
return "", baseFlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err) return "", common.FlagErrorf("--%s cannot read JSON file %q: %v", flagName, path, err)
} }
content := strings.TrimSpace(string(data)) content := strings.TrimSpace(string(data))
if content == "" { if content == "" {
return "", baseFlagErrorf("--%s JSON file %q is empty", flagName, path) return "", common.FlagErrorf("--%s JSON file %q is empty", flagName, path)
} }
return content, nil return content, nil
} }
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
func formatJSONError(flagName string, target string, err error) error { func formatJSONError(flagName string, target string, err error) error {
if syntaxErr, ok := err.(*json.SyntaxError); ok { if syntaxErr, ok := err.(*json.SyntaxError); ok {
return baseFlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName)) return common.FlagErrorf("--%s invalid JSON %s near byte %d (%v); %s", flagName, target, syntaxErr.Offset, err, jsonInputTip(flagName))
} }
if typeErr, ok := err.(*json.UnmarshalTypeError); ok { if typeErr, ok := err.(*json.UnmarshalTypeError); ok {
if typeErr.Field != "" { if typeErr.Field != "" {
return baseFlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName)) return common.FlagErrorf("--%s invalid JSON %s at field %q (%v); %s", flagName, target, typeErr.Field, err, jsonInputTip(flagName))
} }
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName)) return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
} }
return baseFlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName)) return common.FlagErrorf("--%s invalid JSON %s (%v); %s", flagName, target, err, jsonInputTip(flagName))
} }
func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) { func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags []string) (string, error) {
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
} }
} }
if len(active) == 0 { if len(active) == 0 {
return "", baseFlagErrorf("specify one action") return "", common.FlagErrorf("specify one action")
} }
if len(active) > 1 { if len(active) > 1 {
flags := make([]string, 0, len(active)) flags := make([]string, 0, len(active))
for _, item := range active { for _, item := range active {
flags = append(flags, "--"+item) flags = append(flags, "--"+item)
} }
return "", baseFlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", ")) return "", common.FlagErrorf("actions are mutually exclusive: %s", strings.Join(flags, ", "))
} }
return active[0], nil return active[0], nil
} }
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
for idx, item := range arr { for idx, item := range arr {
obj, ok := item.(map[string]interface{}) obj, ok := item.(map[string]interface{})
if !ok { if !ok {
return nil, baseFlagErrorf("--%s item %d must be an object", flagName, idx+1) return nil, common.FlagErrorf("--%s item %d must be an object", flagName, idx+1)
} }
items = append(items, obj) items = append(items, obj)
} }
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
case map[string]interface{}, []interface{}: case map[string]interface{}, []interface{}:
return value, nil return value, nil
default: default:
return nil, baseFlagErrorf("--%s must be a JSON object or array", flagName) return nil, common.FlagErrorf("--%s must be a JSON object or array", flagName)
} }
} }

View File

@@ -133,7 +133,6 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
func TestShortcutsCatalog(t *testing.T) { func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts() shortcuts := Shortcuts()
want := []string{ 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", "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", "+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", "+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, BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk, BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk, BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.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) { func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct { tests := []struct {
name string 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) { func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"} parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{}) BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})

View File

@@ -6,9 +6,9 @@ package base
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
if strings.TrimSpace(raw) == "" { if strings.TrimSpace(raw) == "" {
// text 类型必须提供 data-config含 text 内容) // text 类型必须提供 data-config含 text 内容)
if strings.ToLower(runtime.Str("type")) == "text" { if strings.ToLower(runtime.Str("type")) == "text" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "text 类型组件必须提供 data-config包含必填字段 text").WithParam("--data-config") return fmt.Errorf("text 类型组件必须提供 data-config包含必填字段 text")
} }
return nil return nil
} }

View File

@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
if fieldType == "lookup" { if fieldType == "lookup" {
guidePath = "skills/lark-base/references/lookup-field-guide.md" guidePath = "skills/lark-base/references/lookup-field-guide.md"
} }
return baseFlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath) return common.FlagErrorf("--i-have-read-guide is required for %s when --json.type is %q; read %s first, then retry with --i-have-read-guide", command, fieldType, guidePath)
} }
return nil return nil
} }

View File

@@ -17,7 +17,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core" larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -42,10 +41,10 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
if errors.As(err, &syntaxErr) { if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err) return nil, formatJSONError(flagName, "object", err)
} }
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName)) return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
} }
if result == nil { if result == nil {
return nil, baseFlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName)) return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
} }
return result, nil return result, nil
} }
@@ -153,7 +152,7 @@ func cloneValue(value interface{}) interface{} {
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) { func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
trimmed := strings.TrimSpace(typeName) trimmed := strings.TrimSpace(typeName)
if trimmed == "" { if trimmed == "" {
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty") return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
} }
switch strings.ToLower(trimmed) { switch strings.ToLower(trimmed) {
case "text", "phone", "url", "email", "barcode": case "text", "phone", "url", "email", "barcode":
@@ -193,7 +192,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
case "modifiedtime", "modified_time", "modified-time": case "modifiedtime", "modified_time", "modified-time":
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
default: default:
return fieldTypeSpec{}, baseValidationErrorf("unsupported field type %q in base/v3", typeName) return fieldTypeSpec{}, fmt.Errorf("unsupported field type %q in base/v3", typeName)
} }
} }
@@ -253,10 +252,10 @@ func normalizeSelectOptions(raw interface{}) []interface{} {
func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) { func buildFieldBody(fieldName string, typeName string, property map[string]interface{}, uiType string, description string, isPrimary bool, isHidden bool) (map[string]interface{}, error) {
if isPrimary { if isPrimary {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support setting primary field in field body") return nil, fmt.Errorf("base/v3 does not support setting primary field in field body")
} }
if isHidden { if isHidden {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "base/v3 does not support hidden field creation in field body") return nil, fmt.Errorf("base/v3 does not support hidden field creation in field body")
} }
spec, err := resolveFieldTypeSpec(typeName) spec, err := resolveFieldTypeSpec(typeName)
if err != nil { if err != nil {
@@ -355,7 +354,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
if rawFields != "" { if rawFields != "" {
var fields []interface{} var fields []interface{}
if err := common.ParseJSON([]byte(rawFields), &fields); err != nil { if err := common.ParseJSON([]byte(rawFields), &fields); err != nil {
return nil, baseValidationErrorf("--fields invalid JSON, must be a field definition array") return nil, fmt.Errorf("--fields invalid JSON, must be a field definition array")
} }
return fields, nil return fields, nil
} }
@@ -367,7 +366,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
for _, spec := range specs { for _, spec := range specs {
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false) body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
if err != nil { if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "field %q: %s", spec.Name, err).WithCause(err) return nil, fmt.Errorf("field %q: %w", spec.Name, err)
} }
fields = append(fields, body) fields = append(fields, body)
} }
@@ -411,15 +410,20 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
h.Set("X-App-Id", runtime.Config.AppID) h.Set("X-App-Id", runtime.Config.AppID)
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h)) resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
if err != nil { if err != nil {
return nil, baseAPIBoundaryError(err, "API call failed") return nil, err
}
if _, err := runtime.ClassifyAPIResponse(resp); err != nil {
if statusErr := baseHTTPStatusErrorFromInvalidResponse(resp, err); statusErr != nil {
return nil, statusErr
}
return nil, enrichBaseAPIErrorFromBody(err, resp.RawBody, runtime.APIClassifyContext())
} }
result, parseErr := decodeBaseV3Response(resp.RawBody) result, parseErr := decodeBaseV3Response(resp.RawBody)
if parseErr == nil && baseV3ResultCode(result) != 0 {
attachBaseErrorLogID(result, baseResponseLogID(resp))
return result, nil
}
if resp.StatusCode >= http.StatusBadRequest {
body := strings.TrimSpace(string(resp.RawBody))
if body == "" {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, body)
}
if parseErr != nil { if parseErr != nil {
return nil, parseErr return nil, parseErr
} }
@@ -431,14 +435,18 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(body)) dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber() dec.UseNumber()
if err := dec.Decode(&result); err != nil { if err := dec.Decode(&result); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err) return nil, fmt.Errorf("response parse error: %w", err)
}
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
} }
return result, nil return result, nil
} }
func baseV3ResultCode(result map[string]interface{}) int {
if result == nil {
return 0
}
return toInt(result["code"])
}
func attachBaseErrorLogID(result map[string]interface{}, logID string) { func attachBaseErrorLogID(result map[string]interface{}, logID string) {
if result == nil || strings.TrimSpace(logID) == "" { if result == nil || strings.TrimSpace(logID) == "" {
return return
@@ -472,33 +480,6 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
return strings.TrimSpace(resp.Header.Get("x-tt-logid")) return strings.TrimSpace(resp.Header.Get("x-tt-logid"))
} }
func baseHTTPStatusErrorFromInvalidResponse(resp *larkcore.ApiResp, classified error) error {
if resp == nil || resp.StatusCode < http.StatusBadRequest {
return nil
}
p, ok := errs.ProblemOf(classified)
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
return nil
}
body := strings.TrimSpace(string(resp.RawBody))
if resp.StatusCode >= http.StatusInternalServerError {
err := errs.NewNetworkError(errs.SubtypeNetworkServer, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode).WithRetryable()
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
subtype := errs.SubtypeUnknown
if resp.StatusCode == http.StatusNotFound {
subtype = errs.SubtypeNotFound
}
err := errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).WithCode(resp.StatusCode)
if logID := baseResponseLogID(resp); logID != "" {
err = err.WithLogID(logID)
}
return err
}
func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) {
result, err := baseV3Raw(runtime, method, path, params, data) result, err := baseV3Raw(runtime, method, path, params, data)
return handleBaseAPIResult(result, err, "API call failed") return handleBaseAPIResult(result, err, "API call failed")
@@ -544,7 +525,7 @@ func toStringSlice(v interface{}) []string {
func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) { func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 { if limit <= 0 {
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0") return nil, 0, fmt.Errorf("limit must be greater than 0")
} }
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil) data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil { if err != nil {
@@ -574,7 +555,7 @@ func listAllTables(runtime *common.RuntimeContext, baseToken string, offset, lim
func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) { func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 { if limit <= 0 {
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0") return nil, 0, fmt.Errorf("limit must be greater than 0")
} }
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil) data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "fields"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil { if err != nil {
@@ -596,7 +577,7 @@ func listAllFields(runtime *common.RuntimeContext, baseToken, tableID string, of
func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) { func listAllViews(runtime *common.RuntimeContext, baseToken, tableID string, offset, limit int) ([]map[string]interface{}, int, error) {
if limit <= 0 { if limit <= 0 {
return nil, 0, errs.NewInternalError(errs.SubtypeSDKError, "limit must be greater than 0") return nil, 0, fmt.Errorf("limit must be greater than 0")
} }
data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil) data, err := baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableID, "views"), map[string]interface{}{"offset": offset, "limit": limit}, nil)
if err != nil { if err != nil {
@@ -622,7 +603,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
return field, nil return field, nil
} }
} }
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "field %q not found", ref) return nil, fmt.Errorf("field %q not found", ref)
} }
func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) { func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]interface{}, error) {
@@ -631,7 +612,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
return table, nil return table, nil
} }
} }
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "table %q not found", ref) return nil, fmt.Errorf("table %q not found", ref)
} }
func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) { func resolveViewRef(views []map[string]interface{}, ref string) (map[string]interface{}, error) {
@@ -640,7 +621,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
return view, nil return view, nil
} }
} }
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "view %q not found", ref) return nil, fmt.Errorf("view %q not found", ref)
} }
func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} { func chunkRecords(records []map[string]interface{}, size int) [][]map[string]interface{} {
@@ -757,18 +738,18 @@ func canonicalValue(v interface{}) string {
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) { func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
var tuples []interface{} var tuples []interface{}
if err := common.ParseJSON([]byte(raw), &tuples); err != nil { if err := common.ParseJSON([]byte(raw), &tuples); err != nil {
return nil, baseValidationErrorf("--%s invalid JSON array", flagName) return nil, fmt.Errorf("--%s invalid JSON array", flagName)
} }
result := make([]namedTypeSpec, 0, len(tuples)) result := make([]namedTypeSpec, 0, len(tuples))
for idx, item := range tuples { for idx, item := range tuples {
pair, ok := item.([]interface{}) pair, ok := item.([]interface{})
if !ok || len(pair) != 2 { if !ok || len(pair) != 2 {
return nil, baseValidationErrorf("--%s item %d must be [name, type]", flagName, idx+1) return nil, fmt.Errorf("--%s item %d must be [name, type]", flagName, idx+1)
} }
name, ok1 := pair[0].(string) name, ok1 := pair[0].(string)
typeName, ok2 := pair[1].(string) typeName, ok2 := pair[1].(string)
if !ok1 || !ok2 { if !ok1 || !ok2 {
return nil, baseValidationErrorf("--%s item %d must be [string, string]", flagName, idx+1) return nil, fmt.Errorf("--%s item %d must be [string, string]", flagName, idx+1)
} }
result = append(result, namedTypeSpec{Name: name, Type: typeName}) result = append(result, namedTypeSpec{Name: name, Type: typeName})
} }
@@ -1174,9 +1155,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
return errs return errs
} }
func formatDataConfigErrors(problems []string) error { func formatDataConfigErrors(errs []string) error {
if len(problems) == 0 { if len(errs) == 0 {
return nil return nil
} }
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- ")) return fmt.Errorf("data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(errs, "\n- "))
} }

View File

@@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -20,7 +19,7 @@ func validateRecordReadFormat(runtime *common.RuntimeContext) error {
case "", "json", "markdown": case "", "json", "markdown":
return nil return nil
default: default:
return baseValidationErrorf("--format must be json or markdown") return output.ErrValidation("--format must be json or markdown")
} }
} }
@@ -34,7 +33,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
runtime.Out(data, nil) runtime.Out(data, nil)
return nil return nil
} }
return baseValidationErrorf("--jq and --format markdown are mutually exclusive") return output.ErrValidation("--jq and --format markdown are mutually exclusive")
} }
rendered, err := renderer(data) rendered, err := renderer(data)
if err != nil { if err != nil {
@@ -44,7 +43,7 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
} }
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut) scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked { if scanResult.Blocked {
return baseContentSafetyBlockError(scanResult) return scanResult.BlockErr
} }
if scanResult.Alert != nil { if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert) output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
@@ -53,20 +52,6 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
return nil return nil
} }
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
message := "content safety violation detected"
var rules []string
if scanResult.Alert != nil {
rules = scanResult.Alert.MatchedRules
}
if len(rules) > 0 {
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
}
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
WithRules(rules...).
WithCause(scanResult.BlockErr)
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error { func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown) return outputRecordMarkdownWithRenderer(runtime, data, renderRecordGetMarkdown)
} }
@@ -76,7 +61,7 @@ func renderRecordGetMarkdown(data map[string]interface{}) (string, error) {
recordIDs := stringSliceValue(data["record_id_list"]) recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{}) rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok { if len(fields) == 0 || !ok {
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data") return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
} }
if len(recordIDs) == 1 && len(rows) == 1 { if len(recordIDs) == 1 && len(rows) == 1 {
rowItems, _ := rows[0].([]interface{}) rowItems, _ := rows[0].([]interface{})
@@ -93,7 +78,7 @@ func renderRecordMarkdown(data map[string]interface{}) (string, error) {
recordIDs := stringSliceValue(data["record_id_list"]) recordIDs := stringSliceValue(data["record_id_list"])
rows, ok := data["data"].([]interface{}) rows, ok := data["data"].([]interface{})
if len(fields) == 0 || !ok { if len(fields) == 0 || !ok {
return "", baseValidationErrorf("--format markdown requires record matrix response with fields, record_id_list, and data") return "", output.ErrValidation("--format markdown requires record matrix response with fields, record_id_list, and data")
} }
var b strings.Builder var b strings.Builder

View File

@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety" extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
@@ -213,12 +212,9 @@ func TestOutputRecordMarkdownContentSafetyBlockDoesNotWriteStdout(t *testing.T)
"record_id_list": []interface{}{"rec_1"}, "record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Alice"}}, "data": []interface{}{[]interface{}{"Alice"}},
}) })
var csErr *errs.ContentSafetyError var exitErr *output.ExitError
if !errors.As(err, &csErr) { if !errors.As(err, &exitErr) || exitErr.Code != output.ExitContentSafety {
t.Fatalf("err=%v, want typed content safety error", err) t.Fatalf("err=%v, want content safety exit error", err)
}
if len(csErr.Rules) != 1 || csErr.Rules[0] != "r1" {
t.Fatalf("rules=%v", csErr.Rules)
} }
if stdout.Len() > 0 { if stdout.Len() > 0 {
t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String()) t.Fatalf("block mode should not write stdout, got:\n%s", stdout.String())

View File

@@ -49,7 +49,7 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
fieldIDs := runtime.StrArray("field-id") fieldIDs := runtime.StrArray("field-id")
jsonRaw := strings.TrimSpace(runtime.Str("json")) jsonRaw := strings.TrimSpace(runtime.Str("json"))
if len(recordIDs) > 0 && jsonRaw != "" { if len(recordIDs) > 0 && jsonRaw != "" {
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive") return recordSelection{}, common.FlagErrorf("--record-id and --json are mutually exclusive")
} }
if jsonRaw != "" { if jsonRaw != "" {
pc := newParseCtx(runtime) pc := newParseCtx(runtime)
@@ -59,11 +59,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
} }
recordIDListValue, ok := body["record_id_list"] recordIDListValue, ok := body["record_id_list"]
if !ok { if !ok {
return recordSelection{}, baseFlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json")) return recordSelection{}, common.FlagErrorf(`--json must include "record_id_list" as a non-empty string array; %s`, jsonInputTip("json"))
} }
recordIDItems, ok := recordIDListValue.([]interface{}) recordIDItems, ok := recordIDListValue.([]interface{})
if !ok { if !ok {
return recordSelection{}, baseFlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json")) return recordSelection{}, common.FlagErrorf(`--json field "record_id_list" must be a string array; %s`, jsonInputTip("json"))
} }
normalized, err := normalizeRecordIDs(recordIDItems) normalized, err := normalizeRecordIDs(recordIDItems)
if err != nil { if err != nil {
@@ -117,14 +117,14 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
return fromFlags, nil return fromFlags, nil
} }
if len(fromFlags) > 0 { if len(fromFlags) > 0 {
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`) return nil, common.FlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
} }
items, ok := rawJSONFields.([]interface{}) items, ok := rawJSONFields.([]interface{})
if !ok { if !ok {
return nil, baseFlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json")) return nil, common.FlagErrorf(`--json field "select_fields" must be a string array; %s`, jsonInputTip("json"))
} }
if len(items) == 0 { if len(items) == 0 {
return nil, baseFlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json")) return nil, common.FlagErrorf(`--json field "select_fields" must not be empty; %s`, jsonInputTip("json"))
} }
normalized, err := normalizeRecordGetSelectFields(items) normalized, err := normalizeRecordGetSelectFields(items)
if err != nil { if err != nil {
@@ -152,7 +152,7 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
if opts.allowNil { if opts.allowNil {
return nil, nil return nil, nil
} }
return nil, baseFlagErrorf(opts.typeError) return nil, common.FlagErrorf(opts.typeError)
case []interface{}: case []interface{}:
rawItems = typed rawItems = typed
case []string: case []string:
@@ -161,30 +161,30 @@ func normalizeStringList(values interface{}, opts stringListNormalizeOptions) ([
rawItems = append(rawItems, item) rawItems = append(rawItems, item)
} }
default: default:
return nil, baseFlagErrorf(opts.typeError) return nil, common.FlagErrorf(opts.typeError)
} }
if len(rawItems) == 0 { if len(rawItems) == 0 {
if opts.allowEmpty { if opts.allowEmpty {
return nil, nil return nil, nil
} }
return nil, baseFlagErrorf(opts.emptyError) return nil, common.FlagErrorf(opts.emptyError)
} }
if opts.max > 0 && len(rawItems) > opts.max { if opts.max > 0 && len(rawItems) > opts.max {
return nil, baseFlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems)) return nil, common.FlagErrorf("%s exceeds maximum limit of %d (got %d)", opts.limitName, opts.max, len(rawItems))
} }
seen := make(map[string]int, len(rawItems)) seen := make(map[string]int, len(rawItems))
result := make([]string, 0, len(rawItems)) result := make([]string, 0, len(rawItems))
for index, value := range rawItems { for index, value := range rawItems {
item, ok := value.(string) item, ok := value.(string)
if !ok { if !ok {
return nil, baseFlagErrorf("%s %d must be a string", opts.itemName, index+1) return nil, common.FlagErrorf("%s %d must be a string", opts.itemName, index+1)
} }
item = strings.TrimSpace(item) item = strings.TrimSpace(item)
if item == "" { if item == "" {
return nil, baseFlagErrorf("%s %d must not be empty", opts.itemName, index+1) return nil, common.FlagErrorf("%s %d must not be empty", opts.itemName, index+1)
} }
if first, exists := seen[item]; exists { if first, exists := seen[item]; exists {
return nil, baseFlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1) return nil, common.FlagErrorf("duplicate %s %q at positions %d and %d", opts.duplicateName, item, first, index+1)
} }
seen[item] = index + 1 seen[item] = index + 1
result = append(result, item) result = append(result, item)
@@ -332,10 +332,10 @@ const maxShareBatchSize = 100
func validateRecordShareBatch(runtime *common.RuntimeContext) error { func validateRecordShareBatch(runtime *common.RuntimeContext) error {
recordIDs := deduplicateRecordIDs(runtime) recordIDs := deduplicateRecordIDs(runtime)
if len(recordIDs) == 0 { if len(recordIDs) == 0 {
return baseFlagErrorf("--record-ids is required and must not be empty") return common.FlagErrorf("--record-ids is required and must not be empty")
} }
if len(recordIDs) > maxShareBatchSize { if len(recordIDs) > maxShareBatchSize {
return baseFlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs)) return common.FlagErrorf("--record-ids exceeds maximum limit of %d (got %d)", maxShareBatchSize, len(recordIDs))
} }
return nil return nil
} }

View File

@@ -71,18 +71,18 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
} else if obj, ok := value.(map[string]interface{}); ok { } else if obj, ok := value.(map[string]interface{}); ok {
rawSortConfig, ok := obj["sort_config"] rawSortConfig, ok := obj["sort_config"]
if !ok { if !ok {
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label) return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
} }
parsed, ok := rawSortConfig.([]interface{}) parsed, ok := rawSortConfig.([]interface{})
if !ok { if !ok {
return nil, baseFlagErrorf("%s.sort_config must be a JSON array", label) return nil, common.FlagErrorf("%s.sort_config must be a JSON array", label)
} }
sortConfig = parsed sortConfig = parsed
} else { } else {
return nil, baseFlagErrorf("%s must be a JSON array or an object with sort_config array", label) return nil, common.FlagErrorf("%s must be a JSON array or an object with sort_config array", label)
} }
if len(sortConfig) > recordSortMaxCount { if len(sortConfig) > recordSortMaxCount {
return nil, baseFlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig)) return nil, common.FlagErrorf("sort supports at most %d sort conditions; got %d", recordSortMaxCount, len(sortConfig))
} }
return sortConfig, nil return sortConfig, nil
} }
@@ -90,7 +90,7 @@ func normalizeRecordSortValue(value interface{}, label string) ([]interface{}, e
func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) { func marshalRecordQueryFlag(flagName string, value interface{}) (string, error) {
data, err := json.Marshal(value) data, err := json.Marshal(value)
if err != nil { if err != nil {
return "", baseFlagErrorf("--%s cannot encode JSON: %v", flagName, err) return "", common.FlagErrorf("--%s cannot encode JSON: %v", flagName, err)
} }
return string(data), nil return string(data), nil
} }
@@ -220,16 +220,16 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
jsonRaw := strings.TrimSpace(runtime.Str("json")) jsonRaw := strings.TrimSpace(runtime.Str("json"))
if jsonRaw != "" { if jsonRaw != "" {
if recordSearchHasJSONExclusiveFlagInputs(runtime) { if recordSearchHasJSONExclusiveFlagInputs(runtime) {
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json") return common.FlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
} }
_, err := recordSearchJSONBody(runtime) _, err := recordSearchJSONBody(runtime)
return err return err
} }
if strings.TrimSpace(runtime.Str("keyword")) == "" { if strings.TrimSpace(runtime.Str("keyword")) == "" {
return baseFlagErrorf("--keyword is required unless --json is used") return common.FlagErrorf("--keyword is required unless --json is used")
} }
if len(runtime.StrArray("search-field")) == 0 { if len(runtime.StrArray("search-field")) == 0 {
return baseFlagErrorf("--search-field is required unless --json is used") return common.FlagErrorf("--search-field is required unless --json is used")
} }
return validateRecordQueryOptions(runtime) return validateRecordQueryOptions(runtime)
} }

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