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
530 changed files with 13013 additions and 99879 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/
linters:
- forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer.
- path-except: shortcuts/
@@ -73,20 +65,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/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
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/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
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
text: errs-no-legacy-helper
linters:
- forbidigo
@@ -115,15 +107,17 @@ linters:
msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# These helpers emit legacy output.Err* / bare error shapes or drop
# typed metadata such as Param/Cause. Migrated domains must use typed
# common replacements or local typed helpers instead.
- pattern: (common\.FlagErrorf|common\.RejectDangerousChars|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
# ── legacy shared error helpers banned on drive ──
# These helpers internally produce legacy output.Err* shapes, so they
# are invisible to the errs-typed-only ban above. Drive has migrated its
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
# this prevents reintroduction. Other domains still use the shared
# helpers (migrated globally in a later phase), so this is drive-scoped.
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
msg: >-
[errs-no-legacy-helper] these shared helpers emit legacy or
metadata-poor error shapes. Use typed common replacements, typed
errs.NewXxxError builders, or domain-local typed helpers.
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
shapes. Use the typed errs.NewXxxError builders or the drive-local
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-

View File

@@ -2,83 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.49] - 2026-06-08
### Features
- **events**: Add whiteboard event domain with per-board subscription (#1265)
- **im**: Support feed group (#1102)
- **im**: Add feed shortcut create, list, and remove shortcuts (#1273)
- **im**: Format feed group error handling (#1308)
- **im**: Return typed error envelopes across the im domain (#1230)
- **base**: Emit typed error envelopes across the base domain (#1248)
- **calendar**: Emit typed error envelopes across the calendar domain (#1232)
- **task**: Emit typed error envelopes across the task domain (#1231)
- **okr,whiteboard**: Emit typed error envelopes across both domains (#1236)
- **minutes,vc**: Emit typed error envelopes across both domains (#1234)
- **markdown**: Harden create upload failures (#1325)
- **drive**: Harden inspect shortcut failures (#1324)
- **slides**: Add IconPark lookup for Lark slides (#1123)
- **doc**: Remove docs v1 API (#1291)
- **cli**: Add `skills` command to read embedded skill content (#1318)
- **cli**: Fetch official skills index (#1301)
- **shared**: Document relative-path-only file arguments (#1319)
- **scopes**: Clear `recommend.allow` scope auto-approve overrides (#1272)
- **shortcuts**: Check shortcut example commands against the live CLI tree (#1244)
### Bug Fixes
- **events**: Keep bounded event consume runs alive after stdin EOF (#1285)
- **drive**: Use docs secure label read scope (#1281)
### Documentation
- **approval**: Restructure skill with intent table and scope boundaries (#1307)
- **skills**: Tighten drive and markdown guardrails (#1326)
- **skills**: Optimize calendar, vc, and minutes skill guidance (#1269)
- **markdown**: Add markdown domain template (#1293)
- **markdown**: Improve lark-markdown skill guidance (#1279)
- **doc**: Improve lark-doc skill guidance (#1283)
- **wiki**: Optimize skill guidance and routing boundaries (#1275)
- **slides**: Tighten routing/boundary and reconcile in-slide whiteboard (#1169)
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
@@ -1066,9 +989,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ package cmd
import (
"context"
"io"
"io/fs"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
@@ -17,7 +16,6 @@ import (
"github.com/larksuite/cli/cmd/profile"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/cmd/skill"
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"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.
// When hide is true the flag stays registered (so existing invocations still
// 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 {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
rootCmd := &cobra.Command{
Use: "lark-cli",
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)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
// dumps usage instead of our structured error. SetFlagErrorFunc on root is
// inherited by every subcommand, turning unknown-flag errors into a
// structured "did you mean" envelope.
rootCmd.SilenceUsage = true
rootCmd.SetFlagErrorFunc(flagDidYouMean)
RegisterGlobalFlags(rootCmd.PersistentFlags(), &cfg.globals)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
@@ -155,7 +133,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
rootCmd.AddCommand(completion.NewCmdCompletion(f))
rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f))
rootCmd.AddCommand(cmdevent.NewCmdEvents(f))
rootCmd.AddCommand(skill.NewCmdSkill(f))
service.RegisterServiceCommandsWithContext(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
}
for _, s := range shortcuts.AllShortcuts() {
if s.Service != "" {
seen[s.Service] = true
if s.GetService() != "" {
seen[s.GetService()] = true
}
}
result := make([]string, 0, len(seen))
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
}
for _, sc := range allSC {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
continue
}
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
} else {
merged[k] = &diagMethodEntry{
Domain: domain, Type: "shortcut",
Method: sc.Command,
Method: sc.GetCommand(),
Scope: scope, Identity: []string{identity},
}
}
@@ -148,11 +148,12 @@ func diagBuild(domains []string) diagOutput {
return diagOutput{Methods: methods, Scopes: scopes}
}
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
if len(sc.AuthTypes) == 0 {
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
authTypes := sc.GetAuthTypes()
if len(authTypes) == 0 {
return identity == "user"
}
for _, a := range sc.AuthTypes {
for _, a := range authTypes {
if a == identity {
return true
}

View File

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

View File

@@ -64,8 +64,8 @@ Use 'event schema <EventKey>' for parameter details.`,
cmd.Flags().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr")
cmd.Flags().StringVar(&o.outputDir, "output-dir", "", "Write each event as a file in this directory (relative paths only; absolute paths and ~ are rejected to prevent path traversal)")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop. Bounded runs ignore stdin EOF.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout'). Bounded runs ignore stdin EOF.")
cmd.Flags().IntVar(&o.maxEvents, "max-events", 0, "Exit after N successful emits (0 = unlimited). Multi-worker EventKeys may emit up to workers-1 past N before all workers stop.")
cmd.Flags().DurationVar(&o.timeout, "timeout", 0, "Exit after DURATION (e.g. 30s, 2m). 0 = no timeout. Timeout is a normal exit (code 0; stderr 'reason: timeout').")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)")
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
@@ -184,9 +184,8 @@ func runConsume(cmd *cobra.Command, f *cmdutil.Factory, eventKey string, o consu
errOut = io.Discard
}
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers.
// Bounded runs already have --max-events/--timeout as their lifecycle control.
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
// Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
if !f.IOStreams.IsTerminal {
watchStdinEOF(os.Stdin, cancel, errOut)
}
@@ -371,8 +370,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel()
}()
}
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher: non-TTY unbounded runs only (<= 0 mirrors downstream's >0-is-bounded semantics, so negative bounds stay unbounded).
func shouldWatchStdinEOF(isTerminal bool, maxEvents int, timeout time.Duration) bool {
return !isTerminal && maxEvents <= 0 && timeout <= 0
}

View File

@@ -61,70 +61,3 @@ func TestWatchStdinEOF_DiagnosticMessage(t *testing.T) {
t.Fatal("watchStdinEOF did not cancel within 1s of EOF")
}
}
func TestShouldWatchStdinEOF(t *testing.T) {
tests := []struct {
name string
isTerminal bool
maxEvents int
timeout time.Duration
want bool
}{
{
name: "terminal",
isTerminal: true,
want: false,
},
{
name: "non terminal unbounded",
want: true,
},
{
name: "non terminal negative max events is unbounded",
maxEvents: -1,
want: true,
},
{
name: "non terminal negative timeout is unbounded",
timeout: -1 * time.Second,
want: true,
},
{
name: "non terminal max events bounded",
maxEvents: 1,
want: false,
},
{
name: "non terminal timeout bounded",
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal both bounds positive",
maxEvents: 1,
timeout: 10 * time.Minute,
want: false,
},
{
name: "non terminal bounded max events with negative timeout",
maxEvents: 1,
timeout: -1 * time.Second,
want: false,
},
{
name: "non terminal bounded timeout with negative max events",
maxEvents: -1,
timeout: 10 * time.Minute,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldWatchStdinEOF(tt.isTerminal, tt.maxEvents, tt.timeout)
if got != tt.want {
t.Fatalf("shouldWatchStdinEOF() = %v, want %v", got, tt.want)
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,29 +49,18 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
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 {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
switch strings.Join(args, " ") {
case "-y skills add https://open.feishu.cn --list":
r.Stdout.WriteString("Available Skills\n │ lark-calendar\n │ lark-mail\n")
case "-y skills ls -g --json":
r.Stdout.WriteString(`[{"name":"lark-calendar","path":"/tmp/lark-calendar","scope":"global","agents":["Codex"]},{"name":"custom-skill","path":"/tmp/custom-skill","scope":"global","agents":["Codex"]}]`)
case "-y skills ls -g":
r.Stdout.WriteString("Global Skills\nlark-calendar /tmp/lark-calendar\ncustom-skill /tmp/custom-skill\n")
default:
@@ -487,10 +476,6 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
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 {
t.Fatal("skills sync should not run when binary verification fails")
return nil
@@ -823,11 +808,6 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
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 {
r := &selfupdate.NpmResult{}
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.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 {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -1029,7 +1004,6 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1068,7 +1042,6 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
@@ -1113,7 +1086,6 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1173,10 +1145,6 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1226,10 +1194,6 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
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/minutes"
"github.com/larksuite/cli/events/vc"
"github.com/larksuite/cli/events/whiteboard"
"github.com/larksuite/cli/internal/event"
)
@@ -18,7 +17,6 @@ func init() {
im.Keys(),
minutes.Keys(),
vc.Keys(),
whiteboard.Keys(),
}
for _, keys := range all {
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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2 // flag-error-text contract: see cmd/root.go unknownFlagName
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.9
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -44,8 +43,6 @@ type Factory struct {
Credential *credential.CredentialProvider
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.

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
}
}
// 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 {
case errs.CategoryAuthorization:
@@ -141,11 +129,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
Action: action,
}
case errs.CategoryAPI:
// A server-supplied detail (lifted into base.Hint above) wins over the
// 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
}
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
return &errs.APIError{Problem: base}
default:
// 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
// emitted via BuildAPIError always carries a hint for known config subtypes.
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)
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"
case errs.SubtypeCrossBrand:
return "operate on source and target within the same brand environment"
case errs.SubtypeQuotaExceeded:
return "reduce the request volume or free quota, then retry after the relevant quota resets"
}
return ""
}
@@ -278,10 +256,6 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
}
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
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)
permErr := &errs.PermissionError{
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"
}
// 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.
// Returns nil when the structure is absent.
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
// when the upstream response omits it — wire envelope must omit the field.
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) {
got, ok := LookupCodeMeta(20050)
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

@@ -130,7 +130,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if !opts.Quiet {
fmt.Fprintln(errOut, listeningText(opts))
if !opts.IsTTY {
fmt.Fprintln(errOut, stopHintText(opts))
fmt.Fprintln(errOut, stopHintText())
}
}
@@ -213,11 +213,7 @@ func exitReason(ctx context.Context, emitted int64, opts Options) string {
return "signal"
}
func stopHintText(opts Options) string {
if opts.MaxEvents > 0 || opts.Timeout > 0 {
return "[event] to stop gracefully: send SIGTERM (kill <pid>). " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}
func stopHintText() string {
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions."
}

View File

@@ -50,32 +50,12 @@ func TestListeningText_NonTTY_MaxEventsAndTimeout(t *testing.T) {
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Unbounded(t *testing.T) {
got := stopHintText(Options{})
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
func TestStopHintText_Content(t *testing.T) {
got := stopHintText()
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(unbounded) missing %q; got %q", s, got)
}
}
}
// AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Bounded(t *testing.T) {
cases := []Options{
{MaxEvents: 1},
{Timeout: 30 * time.Second},
}
for _, opts := range cases {
got := stopHintText(opts)
mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got)
}
}
if bytes.Contains([]byte(got), []byte("close stdin")) {
t.Errorf("stopHintText(bounded) must not contain \"close stdin\"; got %q", got)
t.Errorf("stopHintText missing %q; got %q", s, got)
}
}
}

View File

@@ -231,9 +231,14 @@ func TestLoadAutoApproveSet(t *testing.T) {
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"] {
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))
@@ -252,10 +257,16 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
if len(allowSet) == 0 {
t.Fatal("expected non-empty override allow set")
}
// 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) {
// Known auto-approve scope (recommend=true in scope_priorities.json)
if !IsAutoApproveScope("sheets:spreadsheet:read") {
t.Error("expected sheets:spreadsheet:read to be auto-approve")
// Known auto-approve scope (in allow list)
if !IsAutoApproveScope("calendar:calendar.event:create") {
t.Error("expected calendar:calendar.event:create to be auto-approve")
}
// Completely unknown scope
@@ -279,8 +290,9 @@ func TestIsAutoApproveScope(t *testing.T) {
func TestFilterAutoApproveScopes(t *testing.T) {
scopes := []string{
"sheets:spreadsheet:read", // auto-approve (recommend=true in priorities)
"zzz:unknown:scope", // not in auto-approve
"calendar:calendar.event:create", // auto-approve (in allow list)
"zzz:unknown:scope", // not in auto-approve
"sheets:spreadsheet:read", // auto-approve (in allow list)
}
result := FilterAutoApproveScopes(scopes)
@@ -288,10 +300,10 @@ func TestFilterAutoApproveScopes(t *testing.T) {
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
for _, s := range result {
if s == "sheets:spreadsheet:read" {
if s == "calendar:calendar.event:create" {
found = true
}
// Ensure unknown scopes are not included
@@ -300,7 +312,7 @@ func TestFilterAutoApproveScopes(t *testing.T) {
}
}
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
},
"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": [
"im:chat",
"im:message.send_as_user"

View File

@@ -10,13 +10,10 @@ import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"time"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/vfs"
)
@@ -40,15 +37,9 @@ const (
)
const (
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
skillsIndexMaxBodySize = 1 << 20
verifyTimeout = 10 * time.Second
)
var (
skillsIndexFetchTimeout = 10 * time.Second
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
npmInstallTimeout = 10 * time.Minute
skillsUpdateTimeout = 2 * time.Minute
verifyTimeout = 10 * time.Second
)
// DetectResult holds installation detection results.
@@ -92,7 +83,6 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct {
DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error
RestoreAvailableOverride func() bool
@@ -163,53 +153,6 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
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 {
r := u.runSkillsListOfficial("https://open.feishu.cn")
if r.Err != nil {
@@ -222,10 +165,6 @@ func (u *Updater) ListGlobalSkills() *NpmResult {
return u.runSkillsListGlobal()
}
func (u *Updater) ListGlobalSkillsJSON() *NpmResult {
return u.runSkillsCommand("-y", "skills", "ls", "-g", "--json")
}
func (u *Updater) InstallSkill(nameList []string) *NpmResult {
r := u.runSkillsInstall("https://open.feishu.cn", nameList)
if r.Err != nil {

View File

@@ -4,18 +4,12 @@
package selfupdate
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/larksuite/cli/internal/vfs"
)
@@ -194,13 +188,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
},
want: "-y skills ls -g",
},
{
name: "list global json",
run: func(u *Updater) *NpmResult {
return u.ListGlobalSkillsJSON()
},
want: "-y skills ls -g --json",
},
{
name: "install skill primary",
run: func(u *Updater) *NpmResult {
@@ -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) {
called := []string{}
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
import (
"encoding/json"
"fmt"
"regexp"
"sort"
@@ -58,52 +57,6 @@ func ParseSkillsList(text string) []string {
return nil
}
func ParseGlobalSkillsJSON(text string) []string {
type globalSkill struct {
Name string `json:"name"`
}
var skills []globalSkill
if err := json.Unmarshal([]byte(text), &skills); err != nil {
return nil
}
seen := map[string]bool{}
for _, skill := range skills {
candidate := strings.TrimSpace(skill.Name)
if candidate == "" || !skillNamePattern.MatchString(candidate) {
continue
}
seen[candidate] = true
}
return sortedKeys(seen)
}
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"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -124,11 +77,8 @@ func parseGlobalSkillsList(lines []string) []string {
continue
}
if strings.HasPrefix(trimmed, "Agents:") {
continue
}
if isGlobalSkillsSectionHeader(trimmed) {
// Skip indented lines (Agents: ...)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
continue
}
@@ -141,24 +91,21 @@ func parseGlobalSkillsList(lines []string) []string {
candidate := parts[0]
// Validate and add
if candidate == "" || !skillNamePattern.MatchString(candidate) {
if candidate == "" || strings.Contains(candidate, " ") || strings.HasSuffix(candidate, ":") {
continue
}
if !skillNamePattern.MatchString(candidate) {
continue
}
if at := strings.Index(candidate, "@"); at > 0 {
candidate = candidate[:at]
}
seen[candidate] = true
}
return sortedKeys(seen)
}
func isGlobalSkillsSectionHeader(line string) bool {
switch line {
case "General", "Project", "Local":
return true
default:
return false
}
}
// parseOfficialSkillsList parses the output of "npx -y skills add ... --list"
func parseOfficialSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -184,7 +131,8 @@ func parseOfficialSkillsList(lines []string) []string {
if len(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
}
}
@@ -246,9 +194,7 @@ func PlanSync(input SyncInput) SyncPlan {
}
type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
InstallSkill(nameList []string) *selfupdate.NpmResult
InstallAllSkills() *selfupdate.NpmResult
@@ -282,15 +228,21 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 1: List official skills ---
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != 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 ---
local, ok := listLocalSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
local := []string{}
localResult := opts.Runner.ListGlobalSkills()
if localResult != nil && localResult.Err == nil {
local = ParseSkillsList(localResult.Stdout.String())
}
// --- Step 3: Read previous state ---
@@ -346,58 +298,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
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)
// when incremental sync is not possible. On success it writes a state file so that
// subsequent syncs can use incremental mode. When official is non-nil the state

View File

@@ -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) {
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) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -206,22 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialIndexOut string
officialOut string
globalJSONOut string
globalOut string
officialIndexErr error
officialErr error
globalJSONErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
officialOut string
globalOut string
officialErr error
globalErr error
installErr error
installAllErr error
installed [][]string
installedAll int
}
func officialSkillsOutput(names ...string) string {
@@ -235,19 +134,6 @@ func officialSkillsOutput(names ...string) 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 {
var b strings.Builder
b.WriteString("Global Skills\n\n")
@@ -260,45 +146,14 @@ func globalSkillsOutput(names ...string) string {
return b.String()
}
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) 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 {
f.listedOfficial++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr
@@ -331,10 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -346,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState()
if err != nil || !readable {
@@ -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) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
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()
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"),
globalOut: globalSkillsOutput("lark-calendar"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalErr: fmt.Errorf("global list failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 {
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
assertStrings(t, result.SkippedDeleted, []string{})
}
func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
}
if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
}
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
}
}
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
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)
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
}
}
@@ -608,10 +318,9 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
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()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -840,10 +537,8 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

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

View File

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

View File

@@ -1,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
// appending their path prefix here.
var migratedEnvelopePaths = []string{
"shortcuts/base/",
"shortcuts/calendar/",
"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

View File

@@ -18,7 +18,7 @@ import (
// 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.
//
// 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
// typed errs.* errors.
//
@@ -53,7 +53,7 @@ func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
File: path,
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",
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",
})
}

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) {
src := `package drive
@@ -691,7 +662,7 @@ func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
v := CheckNoLegacyEnvelopeLiteral("shortcuts/calendar/foo.go", src)
if len(v) != 0 {
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) {
src := `package drive
@@ -900,14 +851,14 @@ func boom(runtime *common.RuntimeContext) error {
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package contact
src := `package im
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
v := CheckNoLegacyRuntimeAPICall("shortcuts/im/im_send.go", src)
if len(v) != 0 {
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)
}
}
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, CheckNoLegacyEnvelopeLiteral(rel, string(src))...)
all = append(all, CheckNoLegacyRuntimeAPICall(rel, string(src))...)
all = append(all, CheckNoLegacyCommonHelperCall(rel, string(src))...)
// Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

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

View File

@@ -31,7 +31,7 @@ var BaseAdvpermDisable = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
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
},
@@ -55,6 +55,6 @@ var BaseAdvpermDisable = common.Shortcut{
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 {
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
},
@@ -54,6 +54,6 @@ var BaseAdvpermEnable = common.Shortcut{
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"}
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) {
@@ -224,5 +226,7 @@ func TestBaseAdvpermDisableExecuteAPIError(t *testing.T) {
},
})
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.UseNumber()
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"]
_, hasMeas := dsl["measures"]
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
},

View File

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

View File

@@ -4,13 +4,9 @@
package base
import (
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"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.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
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{})
if !ok || resultMap == nil {
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)
}
resultMap, _ := result.(map[string]interface{})
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
return resultMap["data"], nil
}
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{})
}
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's
// 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...)
larkCode := int(code)
msg := extractDataErrorMessage(resultMap)
if strings.TrimSpace(msg) == "" {
msg, _ = resultMap["msg"].(string)
}
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 {
reason := msg
seen := map[string]bool{}
params := []errs.InvalidParam{}
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:]
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
detailMap, ok := detail.(map[string]interface{})
if !ok {
return nil
}
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 {
for _, arg := range args {
if err, ok := arg.(error); ok {
return err
}
func extractErrorDetail(resultMap map[string]interface{}) interface{} {
if detail, ok := nonNilMapValue(resultMap, "error"); ok {
return detail
}
data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := nonNilMapValue(data, "error"); ok {
return detail
}
return nil
}
// baseMissingFileIOError reports a broken runtime wiring: a command that needs
// local file access was constructed without a FileIO provider. The user cannot
// fix this by changing flags, so it classifies as internal, not validation.
func baseMissingFileIOError(format string, args ...any) error {
return errs.NewInternalError(errs.SubtypeFileIO, format, args...)
}
func baseInputStatError(err error) error {
if err == nil {
return nil
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
if src == nil {
return nil, false
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
value, ok := src[key]
if !ok {
return nil, false
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}
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)
switch value.(type) {
case nil:
return nil, false
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 {
if detail, ok := resultMap["error"].(map[string]interface{}); ok {
if hint := consumeStringField(detail, "hint"); hint != "" {

View File

@@ -4,15 +4,30 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
)
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"}
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" {
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") {
t.Fatalf("err=%v", err)
} else {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 190001 {
t.Fatalf("expected typed code 190001, got %T %v", err, err)
}
if p.Hint != "check field name" {
t.Fatalf("hint=%q", p.Hint)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != 190001 {
t.Fatalf("expected structured code 190001, got %v", err)
}
}
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{}{
"code": 800010407,
"msg": "cell value invalid",
@@ -75,27 +87,55 @@ func TestHandleBaseAPIResultPromotesBaseErrorFields(t *testing.T) {
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
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." {
t.Fatalf("message=%q", p.Message)
if errDetail.Hint != "Provide a number value." {
t.Fatalf("hint=%q", errDetail.Hint)
}
if p.Hint != "Provide a number value." {
t.Fatalf("hint=%q", p.Hint)
detail, _ := errDetail.Detail.(map[string]interface{})
if detail == nil {
t.Fatalf("expected cleaned detail, got %#v", errDetail.Detail)
}
if p.LogID != "20260508160000000000000000000000" {
t.Fatalf("logID=%q", p.LogID)
if _, exists := detail["message"]; exists {
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{}{
"code": 99991676,
"code": output.LarkErrTokenNoPermission,
"msg": "permission denied",
"data": map[string]interface{}{
"error": map[string]interface{}{
@@ -106,15 +146,15 @@ func TestHandleBaseAPIResultClassifiesKnownPermissionCode(t *testing.T) {
}
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if p.Code != 99991676 {
t.Fatalf("code=%d", p.Code)
if exitErr.Detail.Message != "Permission denied [99991676]" {
t.Fatalf("message=%q", exitErr.Detail.Message)
}
if p.Category != errs.CategoryAuthorization || p.Subtype != errs.SubtypeTokenScopeInsufficient {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
if exitErr.Detail.Detail != nil {
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")
_, err := handleBaseAPIResultAny(result, nil, "API call failed")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if p.LogID != "20260508170000000000000000000000" {
t.Fatalf("logID=%q", p.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)
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
if detail["logid"] != "20260508170000000000000000000000" {
t.Fatalf("logid=%q", detail["logid"])
}
}
type assertErr struct{}
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"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -412,167 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
return body
}
func TestBaseBlockExecuteShortcuts(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
listStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
},
"total": 2,
},
},
}
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
},
}
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
},
}
renameStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
},
}
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc"},
},
}
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
reg.Register(stub)
}
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
t.Fatalf("list err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
t.Fatalf("list stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
t.Fatalf("list body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
t.Fatalf("create err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("create stdout=%s", got)
}
createBody := decodeCapturedJSONBody(t, createStub)
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
t.Fatalf("create body=%#v", createBody)
}
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
t.Fatalf("move err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
t.Fatalf("move stdout=%s", got)
}
moveBody := decodeCapturedJSONBody(t, moveStub)
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
t.Fatalf("move body=%#v", moveBody)
}
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
t.Fatalf("rename err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
t.Fatalf("rename stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
t.Fatalf("rename body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("delete stdout=%s", got)
}
}
func 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) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -931,10 +769,10 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
t.Run("list-http-404", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404,
RawBody: []byte("404 page not found"),
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404,
Body: "404 page not found",
Headers: map[string][]string{
"Content-Type": {"text/plain"},
},
@@ -2153,9 +1991,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
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) {
@@ -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) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -2538,37 +2356,21 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x",
"--output", "downloads",
}, 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)
}
var partialErr *output.PartialFailureError
if !errors.As(err, &partialErr) {
t.Fatalf("expected partial failure error, got %T %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
downloaded, _ := detail["downloaded"].([]map[string]interface{})
failed, _ := detail["failed"].([]map[string]interface{})
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 {
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
}
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 detail["log_id"] != "202605270001" {
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err)

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsCreate = common.Shortcut{
var questions []interface{}
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",

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -42,7 +43,7 @@ var BaseFormQuestionsDelete = common.Shortcut{
var questionIds []string
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",

View File

@@ -42,7 +42,7 @@ var BaseFormQuestionsUpdate = common.Shortcut{
var questions []interface{}
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",

View File

@@ -14,6 +14,7 @@ import (
"golang.org/x/sync/errgroup"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -61,31 +62,31 @@ func validateFormSubmit(runtime *common.RuntimeContext) error {
attachments, hasAttachments := raw["attachments"]
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 {
// 有附件时 --base-token 必填(上传附件到 Base Drive Media 需要)
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{})
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 {
paths, ok := value.([]interface{})
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 {
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 {
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 {
attObj, ok := attachments.(map[string]interface{})
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 {
attMap = make(map[string][]string, len(attObj))
for fieldName, value := range attObj {
paths, ok := value.([]interface{})
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))
for _, item := range paths {
if s, ok := item.(string); ok {
filePaths = append(filePaths, s)
} 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 {
@@ -194,33 +195,33 @@ func executeFormSubmit(runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
fio := runtime.FileIO()
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: 收集所有唯一路径(跨字段去重)
allPaths := collectUniquePaths(attachmentMap)
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: 前置校验所有文件路径安全性与可访问性,同时收集文件大小供上传使用
sizeMap := make(map[string]int64, len(allPaths))
for _, filePath := range allPaths {
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)
if err != nil {
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 {
return baseValidationErrorf("attachment file %s exceeds 2GB limit", filePath)
return output.ErrValidation("attachment file %s exceeds 2GB limit", filePath)
}
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()
}
@@ -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) {
att, err := uploadAttachmentToBase(runtime, filePath, fileName, fileSize, target)
if err != nil {
return nil, baseUploadAttachmentError(filePath, err)
return nil, fmt.Errorf("failed to upload attachment %s: %w", filePath, err)
}
return att, nil
}

View File

@@ -5,10 +5,9 @@ package base
import (
"encoding/json"
"fmt"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -18,14 +17,6 @@ import (
// - 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).
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 {
var resp struct {
Code int `json:"code"`
@@ -33,17 +24,23 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
Data json.RawMessage `json:"data"`
}
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 {
result := map[string]interface{}{"code": resp.Code, "msg": resp.Msg}
if len(resp.Data) > 0 {
var data interface{}
if json.Unmarshal(resp.Data, &data) == nil {
result["data"] = data
msg := resp.Msg
// When outer msg is empty, try to extract error details from data.error.message
if msg == "" && len(resp.Data) > 0 {
var errData struct {
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) == `""` {
@@ -78,8 +75,7 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
}
if codeInt != 0 {
msg, _ := m["message"].(string)
result := map[string]interface{}{"code": codeInt, "msg": msg, "data": m}
return baseRoleAPIError(runtime, result, action)
return output.ErrAPI(codeInt, fmt.Sprintf("%s: [%d] %s", action, codeInt, msg), nil)
}
// code == 0, extract the inner data if present
if innerData, hasInner := m["data"]; hasInner {
@@ -102,20 +98,3 @@ func handleRoleResponse(runtime *common.RuntimeContext, rawBody []byte, action s
runtime.Out(data, 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 {
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
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
},
@@ -64,6 +64,6 @@ var BaseRoleCreate = common.Shortcut{
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 {
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")) == "" {
return baseFlagErrorf("--role-id must not be blank")
return common.FlagErrorf("--role-id must not be blank")
}
return nil
},
@@ -60,6 +60,6 @@ var BaseRoleDelete = common.Shortcut{
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 {
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")) == "" {
return baseFlagErrorf("--role-id must not be blank")
return common.FlagErrorf("--role-id must not be blank")
}
return nil
},
@@ -58,6 +58,6 @@ var BaseRoleGet = common.Shortcut{
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 {
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
},
@@ -52,6 +52,6 @@ var BaseRoleList = common.Shortcut{
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"}`}
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) {
@@ -403,7 +405,9 @@ func TestBaseRoleListExecuteAPIError(t *testing.T) {
},
})
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) {
@@ -417,7 +421,9 @@ func TestBaseRoleDeleteExecuteAPIError(t *testing.T) {
},
})
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) {
@@ -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"}
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) {
@@ -449,7 +457,9 @@ func TestBaseRoleGetExecuteBusinessError(t *testing.T) {
},
})
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) {
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) {
@@ -562,7 +574,9 @@ func TestHandleRoleResponse(t *testing.T) {
t.Run("business code non-zero", func(t *testing.T) {
rt := newRoleResponseRuntime(t)
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) {

View File

@@ -36,14 +36,14 @@ var BaseRoleUpdate = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
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")) == "" {
return baseFlagErrorf("--role-id must not be blank")
return common.FlagErrorf("--role-id must not be blank")
}
var body map[string]any
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
},
@@ -72,6 +72,6 @@ var BaseRoleUpdate = common.Shortcut{
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) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", baseFlagErrorf("--%s cannot be empty", flagName)
return "", common.FlagErrorf("--%s cannot be empty", flagName)
}
if !strings.HasPrefix(raw, "@") {
return raw, nil
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
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 {
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)
if err != nil {
var pathErr *fileio.PathValidationError
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()
data, err := io.ReadAll(f)
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))
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
}
@@ -68,15 +68,15 @@ func jsonInputTip(flagName string) string {
func formatJSONError(flagName string, target string, err error) error {
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.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) {
@@ -92,14 +92,14 @@ func baseAction(runtime *common.RuntimeContext, boolFlags []string, stringFlags
}
}
if len(active) == 0 {
return "", baseFlagErrorf("specify one action")
return "", common.FlagErrorf("specify one action")
}
if len(active) > 1 {
flags := make([]string, 0, len(active))
for _, item := range active {
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
}
@@ -123,7 +123,7 @@ func parseObjectList(pc *parseCtx, raw string, flagName string) ([]map[string]in
for idx, item := range arr {
obj, ok := item.(map[string]interface{})
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)
}
@@ -150,6 +150,6 @@ func parseJSONValue(pc *parseCtx, raw string, flagName string) (interface{}, err
case map[string]interface{}, []interface{}:
return value, nil
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) {
shortcuts := Shortcuts()
want := []string{
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
@@ -189,7 +188,6 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) {
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
@@ -243,30 +241,6 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
nil,
nil,
)
err := validateBaseBlockMove(runtime)
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
t.Fatalf("err=%v", err)
}
}
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("create err=%v", err)
}
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("rename err=%v", err)
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
@@ -754,79 +728,6 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
}
}
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "list",
shortcut: BaseBaseBlockList,
wantTips: []string{
"lark-cli base +base-block-list --base-token <base_token>",
"lark-cli base +base-block-list --base-token <base_token> --type table",
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`--type docx | jq '.blocks[] | {name, docx_token}'`,
"returned id is the table-id, dashboard-id, or workflow-id",
"For docx blocks, use the returned docx_token with docx commands.",
},
},
{
name: "create",
shortcut: BaseBaseBlockCreate,
wantTips: []string{
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
},
},
{
name: "move",
shortcut: BaseBaseBlockMove,
wantTips: []string{
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
},
},
{
name: "rename",
shortcut: BaseBaseBlockRename,
wantTips: []string{
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
},
},
{
name: "delete",
shortcut: BaseBaseBlockDelete,
wantTips: []string{
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Recursive folder deletion is not supported.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})

View File

@@ -6,9 +6,9 @@ package base
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,7 +47,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
if strings.TrimSpace(raw) == "" {
// text 类型必须提供 data-config含 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
}

View File

@@ -91,7 +91,7 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin
if fieldType == "lookup" {
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
}

View File

@@ -17,7 +17,6 @@ import (
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"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) {
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 {
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
}
@@ -153,7 +152,7 @@ func cloneValue(value interface{}) interface{} {
func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
trimmed := strings.TrimSpace(typeName)
if trimmed == "" {
return fieldTypeSpec{}, baseValidationErrorf("field type cannot be empty")
return fieldTypeSpec{}, fmt.Errorf("field type cannot be empty")
}
switch strings.ToLower(trimmed) {
case "text", "phone", "url", "email", "barcode":
@@ -193,7 +192,7 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
case "modifiedtime", "modified_time", "modified-time":
return fieldTypeSpec{Type: "updated_at", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
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) {
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 {
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)
if err != nil {
@@ -355,7 +354,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
if rawFields != "" {
var fields []interface{}
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
}
@@ -367,7 +366,7 @@ func buildTableFieldBodies(rawFields string, rawFieldSpecs string) ([]interface{
for _, spec := range specs {
body, err := buildFieldBody(spec.Name, normalizeFieldTypeName(spec.Type), nil, "", "", false, false)
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)
}
@@ -411,15 +410,20 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s
h.Set("X-App-Id", runtime.Config.AppID)
resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h))
if err != nil {
return nil, baseAPIBoundaryError(err, "API call failed")
}
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())
return nil, err
}
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 {
return nil, parseErr
}
@@ -431,14 +435,18 @@ func decodeBaseV3Response(body []byte) (map[string]interface{}, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err).WithCause(err)
}
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned a non-object JSON response")
return nil, fmt.Errorf("response parse error: %w", err)
}
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) {
if result == nil || strings.TrimSpace(logID) == "" {
return
@@ -472,33 +480,6 @@ func baseResponseLogID(resp *larkcore.ApiResp) string {
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) {
result, err := baseV3Raw(runtime, method, path, params, data)
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) {
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)
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) {
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)
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) {
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)
if err != nil {
@@ -622,7 +603,7 @@ func resolveFieldRef(fields []map[string]interface{}, ref string) (map[string]in
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) {
@@ -631,7 +612,7 @@ func resolveTableRef(tables []map[string]interface{}, ref string) (map[string]in
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) {
@@ -640,7 +621,7 @@ func resolveViewRef(views []map[string]interface{}, ref string) (map[string]inte
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{} {
@@ -757,18 +738,18 @@ func canonicalValue(v interface{}) string {
func parseNamedTypeSpecs(raw string, flagName string) ([]namedTypeSpec, error) {
var tuples []interface{}
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))
for idx, item := range tuples {
pair, ok := item.([]interface{})
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)
typeName, ok2 := pair[1].(string)
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})
}
@@ -1174,9 +1155,9 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
return errs
}
func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
func formatDataConfigErrors(errs []string) error {
if len(errs) == 0 {
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- "))
}

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