Compare commits

..

2 Commits

Author SHA1 Message Date
shanglei
52dc09af95 style(sidecar): gofmt hmac_test.go
Align comment spacing flagged by the fast-gate gofmt check.
2026-06-02 20:16:42 +08:00
shanglei
07da0c8090 feat(sidecar): support remote HTTPS sidecar addresses
Relax the auth-sidecar proxy address policy so a remote central sidecar
reachable over TLS can be used, while keeping existing same-host plaintext
behavior unchanged.

- ValidateProxyAddr: allow https:// to any host (cross-machine); http://
  and bare host:port stay same-host only; userinfo/path/query/fragment
  remain rejected.
- Add ProxyScheme and route the interceptor URL rewrite through the
  configured scheme (https for remote, http for same-host). ProxyScheme
  parses the address so a mixed-case HTTPS:// cannot silently downgrade to
  plaintext HTTP.
- Update LARKSUITE_CLI_AUTH_PROXY doc and server-demo README for the new
  policy; refresh the package comment.
- Tests: case-insensitive scheme, IPv6 https, https userinfo rejection,
  query/fragment rejection, ProxyHost https forms, and end-to-end
  interceptor scheme selection.
2026-06-02 20:13:47 +08:00
567 changed files with 10628 additions and 103865 deletions

View File

@@ -57,14 +57,6 @@ linters:
- path: internal/vfs/ - path: internal/vfs/
linters: linters:
- forbidigo - forbidigo
# internal/gen build-time generators (standalone `package main` run via
# go:generate) are not shortcut runtime code — no ctx/runtime/framework —
# so the shortcut forbidigo bans don't apply. Going "compliant" is also
# impossible here: a structured error return needs os.Exit (also banned),
# and the vfs.Xxx() alternative is blocked by depguard shortcuts-no-vfs.
- path: shortcuts/.*/internal/gen/
linters:
- forbidigo
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP # shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
# for the client / credential layer. # for the client / credential layer.
- path-except: shortcuts/ - path-except: shortcuts/
@@ -73,23 +65,10 @@ linters:
- forbidigo - forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError. # errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete. # Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/) - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go)
text: errs-typed-only text: errs-typed-only
linters: linters:
- forbidigo - 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)
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/)
text: errs-no-legacy-helper
linters:
- forbidigo
settings: settings:
depguard: depguard:
@@ -115,21 +94,6 @@ linters:
msg: >- msg: >-
[errs-typed-only] use errs.NewXxxError(...) builder [errs-typed-only] use errs.NewXxxError(...) builder
(see errs/types.go). (see errs/types.go).
# ── legacy shared error helpers banned on migrated domains ──
# 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
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.
# ── bare error wraps banned on fully-typed paths ──
- pattern: (fmt\.Errorf|errors\.New)\b
msg: >-
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
wrap a cause with .WithCause(err). Genuine intermediate wraps:
//nolint:forbidigo with a reason.
# ── http: shortcuts must not construct raw HTTP requests ── # ── http: shortcuts must not construct raw HTTP requests ──
# Bans request / client construction; constants (http.MethodPost, # Bans request / client construction; constants (http.MethodPost,
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are # http.StatusOK) and pure helpers (http.StatusText, http.Header) are

View File

@@ -2,68 +2,6 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.0.48] - 2026-06-04
### Features
- **mail**: Preserve mailbox context in `+triage` output for public mailboxes (#1238)
- **contact**: Add contact skill domain guidance (#1144)
### Bug Fixes
- **skills**: Use JSON skills list during update (#1251)
### Documentation
- **drive**: Refine lark-drive knowledge organize workflow (#1253)
- **vc-agent**: Require explicit leave request (#1260)
- **slides**: Add whiteboard element documentation and improve slide guidance (#1029)
## [v1.0.47] - 2026-06-03
### Features
- **sheets**: Add spec-driven shortcut package with backward-compatible wrapper (#1220)
- **base**: Add base block shortcuts (#1044)
- **im**: Complete card message format (#1198)
- **im**: Improve markdown guidance for messages (#1237)
- **vc**: Forward invite call-id on meeting join (#1243)
- **drive**: Emit typed error envelopes across the drive domain (#1205)
- **common**: Emit typed validation errors from shared shortcut pre-checks (#1242)
- **mail**: Validate `message_ids` in `+messages` before batch get (#1202)
- **wiki**: Support `appid` member type (#1235)
- **cli**: Add `--json` flag as no-op alias for `--format json` (#1104)
- **config**: Validate credentials after `config init` (#1151)
### Bug Fixes
- **skills**: Recover empty fallback for skills to update (#1233)
## [v1.0.46] - 2026-06-02
### Features
- **im**: Add card message format support (#1218)
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
- **agent**: Increase agent trace max length to 1024 (#1211)
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
### Bug Fixes
- **cli**: Remove FLAGS section from root `--help` (#1226)
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
### Refactor
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
### Documentation
- **base**: Optimize base skill references (#1171)
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
## [v1.0.45] - 2026-06-01 ## [v1.0.45] - 2026-06-01
### Features ### Features
@@ -1026,9 +964,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48
[v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45 [v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44 [v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43 [v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -341,9 +341,6 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts) printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand}) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
return err
}
return nil return nil
} }
@@ -383,9 +380,6 @@ func configInitRun(opts *ConfigInitOptions) error {
} }
printLangPreferenceConfirmation(opts) printLangPreferenceConfirmation(opts)
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand}) output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
return nil return nil
} }
@@ -425,11 +419,6 @@ func configInitRun(opts *ConfigInitOptions) error {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID)) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
} }
printLangPreferenceConfirmation(opts) printLangPreferenceConfirmation(opts)
if result.AppSecret != "" {
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
return err
}
}
return nil return nil
} }
@@ -518,10 +507,5 @@ func configInitRun(opts *ConfigInitOptions) error {
} }
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath())) output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
printLangPreferenceConfirmation(opts) printLangPreferenceConfirmation(opts)
if appSecretInput != "" {
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
return err
}
}
return nil return nil
} }

View File

@@ -1,91 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
)
// probeTimeout is the total wall-clock budget for the credential probe step
// (covering both TAT acquisition and the subsequent probe request).
const probeTimeout = 3 * time.Second
// runProbe runs a best-effort credential validation after config init has
// persisted the App ID and App Secret. It returns a non-nil error only for a
// deterministic credential-rejection signal; every other outcome returns nil
// so that valid configurations and transient/upstream noise never block the
// command.
//
// The function performs up to two HTTP calls in series, bounded by
// probeTimeout:
//
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
// returns a typed errs.* error (via the shared classifyTATResponseCode)
// only when the server deterministically rejected the credentials — a
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
// so the root dispatcher renders the canonical envelope and `config init`
// exits non-zero — identical to how every other token-resolving command
// reports the same bad credentials. Ambiguous failures (transport errors,
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
// errors and are swallowed (return nil), so valid configurations are never
// disturbed by upstream noise. errs.IsTyped is the discriminator.
//
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
// that call (success, server error, timeout, parse failure) is always
// ignored — return nil regardless.
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
if factory == nil {
return nil
}
httpClient, err := factory.HttpClient()
if err != nil {
return nil
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
if err != nil {
// A typed error from FetchTAT is a deterministic credential rejection
// (classifyTATResponseCode). Propagate it so config init exits with the
// same envelope the rest of the CLI uses for bad credentials. Untyped
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
// silent and let the command succeed.
if errs.IsTyped(err) {
return err
}
return nil
}
// TAT succeeded — fire the probe call. Any outcome is ignored.
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}

View File

@@ -1,288 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// fakeRT routes requests to per-path handlers and records what it saw.
type fakeRT struct {
tatHandler func(req *http.Request) (*http.Response, error)
probeHandler func(req *http.Request) (*http.Response, error)
tatCalls int
probeCalls int
probeReq *http.Request
probeBody string
}
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
f.tatCalls++
if f.tatHandler == nil {
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
}
return f.tatHandler(req)
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
f.probeCalls++
f.probeReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
f.probeBody = string(b)
}
if f.probeHandler == nil {
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
}
return f.probeHandler(req)
}
return nil, errors.New("unexpected URL: " + req.URL.String())
}
func jsonResp(code int, body string) *http.Response {
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}
// fakeFactory builds a test Factory whose HttpClient is overridden to use
// the caller-supplied RoundTripper.
//
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
// guidance). The HttpClient is then swapped to our stub so we can drive
// exact HTTP responses for the probe. Config-dir isolation is set up via
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
// touch lands in a temp dir rather than the developer's real config.
//
// The returned buffer is the Factory's stderr. runProbe never writes to
// stderr (it propagates a typed error or stays silent), so every test asserts
// this buffer stays empty as an invariant.
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
}
return f, errBuf
}
// assertConfigRejection asserts runProbe propagated a deterministic credential
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
// the expected upstream code. This is the same typed error every other
// token-resolving command returns for the same bad credentials, and nothing is
// written to stderr (the root dispatcher renders the envelope).
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
t.Helper()
if err == nil {
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != wantCode {
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
// written to stderr. Used for every ambiguous (non-credential) outcome.
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
t.Helper()
if err != nil {
t.Errorf("expected nil (silent), got error: %v", err)
}
if errBuf.Len() != 0 {
t.Errorf("expected no stderr output, got: %q", errBuf.String())
}
}
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 0 {
t.Error("probe endpoint must not be called when TAT fails")
}
assertConfigRejection(t, err, errBuf, 10003)
}
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
// the most common real-world rejection, propagated.
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
}
// Any non-zero body code is a deterministic rejection and propagates (typed).
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
// typed, so the probe still surfaces it rather than swallowing.
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if err == nil || !errs.IsTyped(err) {
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
}
if errBuf.Len() != 0 {
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
}
}
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
// rejection) → silent, exit 0.
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
for _, code := range []int{401, 403, 500} {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(code, `nope`), nil
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
}
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
return nil, errors.New("network down")
},
}
f, errBuf := fakeFactory(t, rt)
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
rt := &fakeRT{
probeHandler: func(req *http.Request) (*http.Response, error) {
return jsonResp(500, `server error`), nil
},
}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.probeCalls != 1 {
t.Errorf("probe should be called once, got %d", rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
rt := &fakeRT{}
f, errBuf := fakeFactory(t, rt)
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
if rt.tatCalls != 1 || rt.probeCalls != 1 {
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
}
assertSilent(t, err, errBuf)
}
func TestRunProbe_ProbeRequestShape(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if rt.probeReq.Method != http.MethodPost {
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
}
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
t.Errorf("probe URL = %s", got)
}
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
t.Errorf("Authorization = %q, want Bearer t-ok", got)
}
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
t.Errorf("probe body missing from field: %s", rt.probeBody)
}
}
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
rt := &fakeRT{}
f, _ := fakeFactory(t, rt)
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rt.probeReq == nil {
t.Fatal("probe request not captured")
}
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
}
}
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
f.HttpClient = func() (*http.Client, error) {
return nil, errors.New("client init failed")
}
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
}
func TestRunProbe_TimeoutHonored(t *testing.T) {
rt := &fakeRT{
tatHandler: func(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
},
}
f, errBuf := fakeFactory(t, rt)
start := time.Now()
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
}
// A timeout is an ambiguous failure (context deadline → untyped), so it
// must stay silent and not block.
assertSilent(t, err, errBuf)
}

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().StringVar(&o.jqExpr, "jq", "", "JQ expression to filter output")
cmd.Flags().BoolVar(&o.quiet, "quiet", false, "Suppress informational messages on stderr") 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().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().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'). 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').")
cmd.Flags().String("as", "auto", "identity type: user | bot | auto (must match EventKey's declared AuthTypes)") 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) { _ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp 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 errOut = io.Discard
} }
// Non-TTY unbounded consumers use stdin EOF as shutdown for subprocess callers. // Non-TTY only: stdin EOF is shutdown for subprocess callers; in TTY Ctrl-D must not exit.
// Bounded runs already have --max-events/--timeout as their lifecycle control. if !f.IOStreams.IsTerminal {
if shouldWatchStdinEOF(f.IOStreams.IsTerminal, o.maxEvents, o.timeout) {
watchStdinEOF(os.Stdin, cancel, errOut) watchStdinEOF(os.Stdin, cancel, errOut)
} }
@@ -371,8 +370,3 @@ func watchStdinEOF(r io.Reader, cancel context.CancelFunc, errOut io.Writer) {
cancel() 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") 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" eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/suggest"
) )
const maxSuggestions = 3 const maxSuggestions = 3
@@ -29,7 +28,7 @@ func suggestEventKeys(input string) []string {
hits = append(hits, match{def.Key, 0}) hits = append(hits, match{def.Key, 0})
continue continue
} }
if d := suggest.Levenshtein(input, def.Key); d <= threshold { if d := levenshtein(input, def.Key); d <= threshold {
hits = append(hits, match{def.Key, d}) hits = append(hits, match{def.Key, d})
} }
} }
@@ -70,3 +69,34 @@ func unknownEventKeyErr(key string) error {
"Run 'lark-cli event list' to see available keys.", "Run 'lark-cli event list' to see available keys.",
) )
} }
// levenshtein computes classic edit distance (two-row DP).
func levenshtein(a, b string) int {
if a == b {
return 0
}
ra, rb := []rune(a), []rune(b)
if len(ra) == 0 {
return len(rb)
}
if len(rb) == 0 {
return len(ra)
}
prev := make([]int, len(rb)+1)
curr := make([]int, len(rb)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ra); i++ {
curr[0] = i
for j := 1; j <= len(rb); j++ {
cost := 1
if ra[i-1] == rb[j-1] {
cost = 0
}
curr[j] = min(prev[j]+1, curr[j-1]+1, prev[j-1]+cost)
}
prev, curr = curr, prev
}
return prev[len(rb)]
}

View File

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

View File

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

View File

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

View File

@@ -18,17 +18,14 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/errclass" "github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/errcompat" "github.com/larksuite/cli/internal/errcompat"
"github.com/larksuite/cli/internal/hook" "github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/pflag"
) )
const rootLong = `lark-cli — Lark/Feishu CLI tool. const rootLong = `lark-cli — Lark/Feishu CLI tool.
@@ -51,6 +48,20 @@ EXAMPLES:
# Generic API call # Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars lark-cli api GET /open-apis/calendar/v4/calendars
FLAGS:
--params <json> URL/query parameters JSON
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
--as <type> identity type: user | bot
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
--page-all automatically paginate through all pages
--page-size <N> page size (0 = use API default)
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
-o, --output <path> output file path for binary responses
--jq <expr> jq expression to filter JSON output
-q <expr> shorthand for --jq
--dry-run print request without executing
AI AGENT SKILLS: AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows. teach the agent Lark API patterns, best practices, and workflows.
@@ -72,15 +83,7 @@ COMMUNITY:
More help: lark-cli <command> --help` More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code. // Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
// before they reach a group's RunE, so unknownSubcommandRunE re-derives them
// from here. It stays nil in unit tests that invoke a RunE directly with
// explicit args — correct, since those don't exercise the whitelist path.
var rawInvocationArgs []string
func Execute() int { func Execute() int {
rawInvocationArgs = os.Args[1:]
inv, err := BootstrapInvocationContext(os.Args[1:]) inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err) fmt.Fprintln(os.Stderr, "Error:", err)
@@ -144,14 +147,7 @@ func setupNotices() {
skillscheck.Init(build.Version) skillscheck.Init(build.Version)
// Composed notice provider — emits keys only when each pending is set. // Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice output.PendingNotice = func() map[string]interface{} {
}
// 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{}{} notice := map[string]interface{}{}
if info := update.GetPending(); info != nil { if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{ notice["update"] = map[string]interface{}{
@@ -169,25 +165,12 @@ func composePendingNotice() map[string]interface{} {
"command": "lark-cli update", "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 { if len(notice) == 0 {
return nil return nil
} }
return notice return notice
} }
}
// isCompletionCommand returns true if args indicate a shell completion request. // isCompletionCommand returns true if args indicate a shell completion request.
// Update notifications and Shutdown lifecycle emits must be suppressed for // Update notifications and Shutdown lifecycle emits must be suppressed for
@@ -272,13 +255,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return typedExit return typedExit
} }
// Partial-failure (batch / multi-status): the ok:false result envelope is
// already on stdout; set the exit code and write nothing to stderr.
var pfErr *output.PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
if exitErr := asExitError(err); exitErr != nil { if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw { if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw) // Raw errors (e.g. from `api` command via output.MarkRaw)
@@ -291,19 +267,6 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code return exitErr.Code
} }
// A backward-compat alias records its deprecation notice in PreRunE, which
// runs before cobra's required-flag validation — but a missing required flag
// fails before RunE and lands here, where the bare "Error:" line would drop
// the notice. When a deprecation is pending, route through the structured
// envelope so the migration hint still reaches the caller; all other errors
// keep the existing plain output.
if deprecation.GetPending() != nil {
output.WriteErrorEnvelope(errOut, &output.ExitError{
Code: 1,
Detail: &output.ErrDetail{Type: "validation", Message: err.Error()},
}, string(f.ResolvedIdentity))
return 1
}
fmt.Fprintln(errOut, "Error:", err) fmt.Fprintln(errOut, "Error:", err)
return 1 return 1
} }
@@ -345,12 +308,6 @@ func asExitError(err error) *output.ExitError {
func installUnknownSubcommandGuard(cmd *cobra.Command) { func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil { if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE cmd.RunE = unknownSubcommandRunE
// Route an unknown subcommand to unknownSubcommandRunE even when flags
// are also present (e.g. `sheets +cells-find --url ...`). A pure group
// consumes no flags itself, so unknown flags belong to the (missing)
// subcommand; whitelisting them here prevents cobra from erroring on the
// flag first and printing usage instead of our structured suggestion.
cmd.FParseErrWhitelist.UnknownFlags = true
if cmd.Annotations == nil { if cmd.Annotations == nil {
cmd.Annotations = map[string]string{} cmd.Annotations = map[string]string{}
} }
@@ -370,89 +327,14 @@ func installUnknownSubcommandGuard(cmd *cobra.Command) {
// they have moved to the typed surface. // they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error { func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 { if len(args) == 0 {
// A bare group (e.g. `sheets`), or one carrying only group-valid flags
// 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() return cmd.Help()
} }
if unknown := unknownFlagTokens(cmd, rawInvocationArgs); len(unknown) > 0 {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_flag",
Message: fmt.Sprintf("unknown flag %s before a subcommand for %q", strings.Join(unknown, ", "), cmd.CommandPath()),
Hint: fmt.Sprintf("flags belong to a subcommand; run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
// Keep the same detail keys as flagDidYouMean's unknown_flag
// so a consumer keyed on Type can read a stable shape. The
// subcommand isn't resolved here, so suggestions/valid_flags
// have no meaningful universe to draw from — emit empty
// rather than the group's own (misleading) flags. unknown is
// the back-compat singular field; unknown_flags carries the
// full list when more than one flag was supplied.
"unknown": strings.Join(unknown, ", "),
"unknown_flags": unknown,
"command_path": cmd.CommandPath(),
"suggestions": []string{},
"valid_flags": []string{},
},
},
}
}
// The remaining flags are all defined somewhere in the tree. Those valid
// on the group itself or inherited (e.g. the global --profile) do not
// require a subcommand, so a bare group carrying only those still prints
// help. Anything left belongs to a subcommand that was omitted
// (e.g. `im --format json`): distinct from unknown_flag — the flags are
// real, the subcommand is what's missing.
misplaced := subcommandOnlyFlagTokens(cmd, rawInvocationArgs)
if len(misplaced) == 0 {
return cmd.Help()
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "missing_subcommand",
Message: fmt.Sprintf("missing subcommand for %q; flag %s belongs to a subcommand, not the group", cmd.CommandPath(), strings.Join(misplaced, ", ")),
Hint: fmt.Sprintf("run `%s --help` to list subcommands and their flags", cmd.CommandPath()),
Detail: map[string]any{
"command_path": cmd.CommandPath(),
"flags": misplaced,
"suggestions": []string{},
},
},
}
}
unknown := args[0] unknown := args[0]
available, deprecated := availableSubcommandNames(cmd) available := availableSubcommandNames(cmd)
// Rank suggestions across both current and deprecated names so a mistyped
// legacy command (e.g. +raed → +read) still resolves; the alias stays
// runnable and self-flags via the _notice on execution.
suggestions := suggest.Closest(unknown, append(append([]string{}, available...), deprecated...), 6)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath()) msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath()) hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(suggestions) > 0 { if len(available) > 0 {
hint = fmt.Sprintf("did you mean one of: %s? (run `%s --help` for the full list)", hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
strings.Join(suggestions, ", "), cmd.CommandPath())
}
detail := map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"suggestions": suggestions,
"available": available,
}
// Only services with backward-compat aliases (currently sheets) carry a
// deprecated bucket; omit the key elsewhere so every other service's
// envelope is unchanged.
if len(deprecated) > 0 {
detail["deprecated"] = deprecated
} }
return &output.ExitError{ return &output.ExitError{
Code: output.ExitValidation, Code: output.ExitValidation,
@@ -460,114 +342,17 @@ func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
Type: "unknown_subcommand", Type: "unknown_subcommand",
Message: msg, Message: msg,
Hint: hint, Hint: hint,
Detail: detail, Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
}, },
} }
} }
// flagTokensInArgs returns the flag-like tokens (-x, --foo, --foo=bar) in func availableSubcommandNames(cmd *cobra.Command) []string {
// rawArgs, stopping at the "--" positional terminator. Whether a flag is subs := make([]string, 0, len(cmd.Commands()))
// defined is not considered (see unknownFlagTokens for that). A pure group
// with any flag token but no subcommand is a user error — a pure group
// consumes no flags of its own, so the flag must belong to a subcommand — so
// the caller fails structured instead of falling through to help.
func flagTokensInArgs(rawArgs []string) []string {
var toks []string
for _, a := range rawArgs {
if a == "--" {
break // everything after -- is positional
}
if len(a) < 2 || a[0] != '-' {
continue
}
toks = append(toks, a)
}
return toks
}
// unknownFlagTokens returns the flag tokens in rawArgs that cmd does not define
// (on itself, inherited, or any direct subcommand). installUnknownSubcommandGuard
// whitelists unknown flags on pure groups so a mistyped subcommand still reaches
// the suggestion path; the side effect is that flags before a subcommand are
// swallowed. This recovers the genuinely-unknown ones so the caller can name
// them in a "did you mean" envelope.
func unknownFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var unknown []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name != "" && !flagDefinedInTree(cmd, name) {
unknown = append(unknown, a)
}
}
return unknown
}
// flagKnownOnGroup reports whether name is a flag defined on cmd itself or
// inherited (a global persistent flag like --profile) — i.e. valid on the bare
// group and therefore not requiring a subcommand.
func flagKnownOnGroup(cmd *cobra.Command, name string) bool {
short := len(name) == 1
lookup := func(fs *pflag.FlagSet) bool {
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
return lookup(cmd.Flags()) || lookup(cmd.InheritedFlags())
}
// subcommandOnlyFlagTokens returns the flag tokens in rawArgs that are valid on
// a subcommand of cmd but not on cmd itself/inherited — flags supplied while
// omitting the subcommand they belong to (`im --format json`). Global flags
// valid on the bare group (e.g. --profile) are excluded so
// `lark-cli --profile p im` still prints help rather than erroring.
func subcommandOnlyFlagTokens(cmd *cobra.Command, rawArgs []string) []string {
var misplaced []string
for _, a := range flagTokensInArgs(rawArgs) {
name := strings.SplitN(strings.TrimLeft(a, "-"), "=", 2)[0]
if name == "" || flagKnownOnGroup(cmd, name) {
continue
}
if flagDefinedInTree(cmd, name) {
misplaced = append(misplaced, a)
}
}
return misplaced
}
// flagDefinedInTree reports whether name is defined on cmd, its inherited
// (persistent) flags, or any direct subcommand. The subcommand case covers a
// user who merely omitted the subcommand — e.g. `sheets --format json`, where
// --format is injected on every leaf shortcut, not on the group — so only a
// genuinely unknown flag like `sheets --badflag` is reported.
func flagDefinedInTree(cmd *cobra.Command, name string) bool {
short := len(name) == 1
known := func(c *cobra.Command, inherited bool) bool {
fs := c.Flags()
if inherited {
fs = c.InheritedFlags()
}
if short {
return fs.ShorthandLookup(name) != nil
}
return fs.Lookup(name) != nil
}
if known(cmd, false) || known(cmd, true) {
return true
}
for _, c := range cmd.Commands() {
if known(c, false) {
return true
}
}
return false
}
// availableSubcommandNames returns the invokable subcommand names of cmd, split
// into current commands and backward-compatibility aliases (those tagged into
// the deprecated cobra group via cmdutil.DeprecatedGroupID). Both slices are
// sorted; hidden commands plus help/completion are omitted.
func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []string) {
for _, c := range cmd.Commands() { for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() { if c.Hidden || !c.IsAvailableCommand() {
continue continue
@@ -576,95 +361,10 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin
if name == "help" || name == "completion" { if name == "help" || name == "completion" {
continue continue
} }
if cmdutil.IsDeprecatedCommand(c) { subs = append(subs, name)
deprecated = append(deprecated, name)
} else {
available = append(available, name)
} }
} sort.Strings(subs)
sort.Strings(available) return subs
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
} }
// installTipsHelpFunc wraps the default help function to append a TIPS section // installTipsHelpFunc wraps the default help function to append a TIPS section

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -155,30 +155,7 @@ caller scripts.
New code should not reach for `ErrBare` unless the command is New code should not reach for `ErrBare` unless the command is
genuinely a predicate. Anything carrying recoverable error content genuinely a predicate. Anything carrying recoverable error content
belongs in a typed `*errs.XxxError` — or, for a batch result, in the belongs in a typed `*errs.XxxError`.
partial-failure outcome below.
### Partial failure (batch / multi-status)
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
many items can finish in a third state, neither full success nor a single
error: some items succeeded and some failed. Its primary output is the
per-item result, so it does **not** belong in a `stderr` error envelope.
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
1. writes the full result to **stdout** as an `ok:false` envelope — the
summary and every per-item outcome (succeeded *and* failed) stay
machine-readable, exactly as a successful `Out(...)` would carry them,
but with `ok` honestly reporting failure; and
2. returns `*output.PartialFailureError`, a typed exit signal the
dispatcher maps to a non-zero exit code while writing nothing further
to `stderr`.
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
*result*, reported on stdout, that also failed. Consumers branch on
`ok == false` and then read `data.summary` / `data.items[]`.
## Consumers ## Consumers

View File

@@ -13,7 +13,6 @@ const (
// CategoryValidation subtypes // CategoryValidation subtypes
const ( const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment) SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
) )
// CategoryAuthentication subtypes // CategoryAuthentication subtypes

View File

@@ -62,23 +62,9 @@ type TypedError interface {
type ValidationError struct { type ValidationError struct {
Problem Problem
Param string `json:"param,omitempty"` Param string `json:"param,omitempty"`
Params []InvalidParam `json:"params,omitempty"`
Cause error `json:"-"` Cause error `json:"-"`
} }
// InvalidParam is one structured validation diagnostic: the parameter that
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
// item (RFC 7807 §3.1 extension members).
//
// The wire key on ValidationError is "params" rather than "invalid_params"
// because the enclosing envelope already carries type:"validation", so the
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
// InvalidParam prefix because, at package level, the name must self-describe.
type InvalidParam struct {
Name string `json:"name"`
Reason string `json:"reason"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse // Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
// it. A nil typed-pointer held inside an error interface is treated as // it. A nil typed-pointer held inside an error interface is treated as
// "no cause" so callers cannot panic on `errors.Unwrap(err)`. // "no cause" so callers cannot panic on `errors.Unwrap(err)`.
@@ -136,11 +122,6 @@ func (e *ValidationError) WithParam(param string) *ValidationError {
return e return e
} }
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
e.Params = append(e.Params, params...)
return e
}
func (e *ValidationError) WithCause(cause error) *ValidationError { func (e *ValidationError) WithCause(cause error) *ValidationError {
e.Cause = cause e.Cause = cause
return e return e

View File

@@ -558,71 +558,6 @@ func TestTypedError_UnwrapSymmetry(t *testing.T) {
}) })
} }
// TestValidationError_WithParams covers the structured-validation extension:
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
// and the wire shape nests {name, reason} under "params" (omitted when empty).
func TestValidationError_WithParams(t *testing.T) {
t.Run("appends and exposes fields", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
if len(e.Params) != 1 {
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
}
if e.Params[0].Name != "a.md" {
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
}
if e.Params[0].Reason != "duplicate" {
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
}
})
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
if returned != e {
t.Errorf("WithParams returned different pointer; want same as receiver")
}
e.WithParams(
errs.InvalidParam{Name: "b.md", Reason: "dup"},
errs.InvalidParam{Name: "c.md", Reason: "dup"},
)
if len(e.Params) != 3 {
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
}
})
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
WithParam("--rel-path").
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
got := string(b)
for _, want := range []string{
`"type":"validation"`,
`"param":"--rel-path"`,
`"params":[{"name":"a.md","reason":"duplicate"}]`,
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in %s", want, got)
}
}
})
t.Run("empty Params omitted from wire", func(t *testing.T) {
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
b, err := json.Marshal(e)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(b), `"params"`) {
t.Errorf("empty Params should be omitted from wire; got %s", b)
}
})
}
func TestBuilderSetter_DefensiveCopy(t *testing.T) { func TestBuilderSetter_DefensiveCopy(t *testing.T) {
t.Run("WithMissingScopes clones input", func(t *testing.T) { t.Run("WithMissingScopes clones input", func(t *testing.T) {
scopes := []string{"docx:document", "im:message:send"} scopes := []string{"docx:document", "im:message:send"}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,12 @@
//go:build authsidecar //go:build authsidecar
// Package sidecar provides a transport interceptor for the auth sidecar // Package sidecar provides a transport interceptor for the auth sidecar
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all // proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
// outgoing requests are rewritten to the sidecar address. The interceptor // URL), all outgoing requests are rewritten to the sidecar address. The
// strips placeholder credentials, injects proxy headers, and signs each // interceptor strips placeholder credentials, injects proxy headers, and
// request with HMAC-SHA256. No custom DialContext is needed — Go's // signs each request with HMAC-SHA256. No custom DialContext is needed —
// standard http.Transport connects to the sidecar via plain HTTP. // Go's standard http.Transport connects to the sidecar via HTTP, or via
// HTTPS (TLS) when the sidecar address is an https:// URL.
package sidecar package sidecar
import ( import (
@@ -48,13 +49,15 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
return &Interceptor{ return &Interceptor{
key: []byte(key), key: []byte(key),
sidecarHost: sidecar.ProxyHost(proxyAddr), sidecarHost: sidecar.ProxyHost(proxyAddr),
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
} }
} }
// Interceptor rewrites requests for the sidecar proxy. // Interceptor rewrites requests for the sidecar proxy.
type Interceptor struct { type Interceptor struct {
key []byte // HMAC signing key key []byte // HMAC signing key
sidecarHost string // sidecar host:port for URL rewriting sidecarHost string // sidecar host[:port] for URL rewriting
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
} }
// PreRoundTrip rewrites the request for sidecar routing when it carries a // PreRoundTrip rewrites the request for sidecar routing when it carries a
@@ -130,8 +133,13 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
req.Header.Set(sidecar.HeaderProxyTimestamp, ts) req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
req.Header.Set(sidecar.HeaderProxySignature, sig) req.Header.Set(sidecar.HeaderProxySignature, sig)
// 5. Rewrite URL to route through sidecar // 5. Rewrite URL to route through sidecar. Scheme follows the configured
req.URL.Scheme = "http" // proxy address: https for a remote (TLS) sidecar, http for a same-host one.
scheme := i.sidecarScheme
if scheme == "" {
scheme = "http"
}
req.URL.Scheme = scheme
req.URL.Host = i.sidecarHost req.URL.Host = i.sidecarHost
return nil // no post-hook needed return nil // no post-hook needed

View File

@@ -7,11 +7,13 @@ package sidecar
import ( import (
"bytes" "bytes"
"context"
"errors" "errors"
"io" "io"
"net/http" "net/http"
"testing" "testing"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar" "github.com/larksuite/cli/sidecar"
) )
@@ -97,6 +99,54 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
} }
} }
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
// rewrites the request to https://<remote-host>, while still preserving the
// original target and signing the request.
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
key := []byte("test-key-for-hmac-signing-32byte!")
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
interceptor.PreRoundTrip(req)
if req.URL.Scheme != "https" {
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
}
if req.URL.Host != "sidecar.mycorp.com" {
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
}
// Original target still preserved for the sidecar to forward upstream.
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
}
// Request is still signed.
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
t.Error("signature header should be set")
}
}
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
// (mixed-case) https proxy address must produce an interceptor that rewrites to
// https, never silently downgrading a remote sidecar to plaintext http.
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
t.Setenv(envvars.CliProxyKey, "key")
ic := (&Provider{}).ResolveInterceptor(context.Background())
si, ok := ic.(*Interceptor)
if !ok || si == nil {
t.Fatalf("expected *Interceptor, got %T", ic)
}
if si.sidecarScheme != "https" {
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
}
if si.sidecarHost != "sidecar.mycorp.com" {
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
}
}
func TestInterceptor_BotIdentity(t *testing.T) { func TestInterceptor_BotIdentity(t *testing.T) {
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"} interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}

2
go.mod
View File

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

View File

@@ -153,79 +153,9 @@ func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as c
if err != nil { if err != nil {
return nil, WrapDoAPIError(err) return nil, WrapDoAPIError(err)
} }
c.logAPIResponse(req, resp)
return resp, nil return resp, nil
} }
func (c *APIClient) logAPIResponse(req *larkcore.ApiReq, resp *larkcore.ApiResp) {
if resp == nil {
return
}
logID := strings.TrimSpace(resp.LogId())
if logID == "" {
return
}
method, path := apiReqLogFields(req, "")
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
}
func (c *APIClient) logStreamResponse(req *larkcore.ApiReq, requestURL string, resp *http.Response) {
if resp == nil {
return
}
logID := streamLogID(resp.Header)
if logID == "" {
return
}
method, path := apiReqLogFields(req, requestURL)
fmt.Fprintf(c.errOut(), "[lark-cli] api-response: method=%s path=%s status=%d log_id=%s\n", method, path, resp.StatusCode, logID)
}
func (c *APIClient) errOut() io.Writer {
if c != nil && c.ErrOut != nil {
return c.ErrOut
}
return io.Discard
}
func apiReqLogFields(req *larkcore.ApiReq, fallbackURL string) (string, string) {
method := ""
path := ""
if req != nil {
method = req.HttpMethod
path = req.ApiPath
}
method = strings.ToUpper(strings.TrimSpace(method))
if method == "" {
method = "UNKNOWN"
}
path = requestLogPath(path)
if path == "missing" {
path = requestLogPath(fallbackURL)
}
return method, path
}
func requestLogPath(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return "missing"
}
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
if u.EscapedPath() != "" {
return u.EscapedPath()
}
return "/"
}
if i := strings.Index(raw, "?"); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "missing"
}
return raw
}
// DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint. // DoStream executes a streaming HTTP request against the Lark OpenAPI endpoint.
// Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns // Unlike DoSDKRequest (which buffers the full body via the SDK), DoStream returns
// a live *http.Response whose Body is an io.Reader for streaming consumption. // a live *http.Response whose Body is an io.Reader for streaming consumption.
@@ -294,7 +224,6 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err) return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
} }
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel} resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
c.logStreamResponse(req, requestURL, resp)
// Handle HTTP errors internally // Handle HTTP errors internally
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {

View File

@@ -464,48 +464,6 @@ func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
} }
} }
func TestDoStream_LogsLogIDToErrOut(t *testing.T) {
errBuf := &bytes.Buffer{}
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/octet-stream"},
larkcore.HttpHeaderKeyLogId: []string{"stream-log-123"},
},
Body: io.NopCloser(strings.NewReader("ok")),
}, nil
})
ac := &APIClient{
HTTP: &http.Client{Transport: rt},
ErrOut: errBuf,
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
}
resp, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
}, core.AsBot)
if err != nil {
t.Fatalf("DoStream() error = %v", err)
}
defer resp.Body.Close()
got := errBuf.String()
for _, want := range []string{
"[lark-cli] api-response:",
"method=GET",
"path=/open-apis/drive/v1/medias/file_token/download",
"status=200",
"log_id=stream-log-123",
} {
if !strings.Contains(got, want) {
t.Fatalf("log missing %q; got:\n%s", want, got)
}
}
}
// failingTokenResolver always returns TokenUnavailableError, exercising the // failingTokenResolver always returns TokenUnavailableError, exercising the
// auth/credential failure path through resolveAccessToken. // auth/credential failure path through resolveAccessToken.
type failingTokenResolver struct{} type failingTokenResolver struct{}
@@ -660,41 +618,6 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
} }
} }
func TestDoSDKRequest_LogsLogIDToErrOut(t *testing.T) {
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"application/json"},
larkcore.HttpHeaderKeyLogId: []string{"sdk-log-123"},
},
Body: io.NopCloser(strings.NewReader(`{"code":0,"msg":"ok","data":{}}`)),
}, nil
})
ac, errBuf := newTestAPIClient(t, rt)
_, err := ac.DoSDKRequest(context.Background(), &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/contact/v3/users/me",
}, core.AsBot)
if err != nil {
t.Fatalf("DoSDKRequest() error = %v", err)
}
got := errBuf.String()
for _, want := range []string{
"[lark-cli] api-response:",
"method=GET",
"path=/open-apis/contact/v3/users/me",
"status=200",
"log_id=sdk-log-123",
} {
if !strings.Contains(got, want) {
t.Fatalf("log missing %q; got:\n%s", want, got)
}
}
}
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for // TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
// malformed JSON response bodies: WrapJSONResponseParseError emits // malformed JSON response bodies: WrapJSONResponseParseError emits
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint // *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,14 +28,8 @@ const (
HeaderShortcut = "X-Cli-Shortcut" HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id" HeaderExecutionId = "X-Cli-Execution-Id"
HeaderAgentTrace = "X-Agent-Trace" HeaderAgentTrace = "X-Agent-Trace"
HeaderTTEnv = "X-Tt-Env"
HeaderUsePPE = "X-Use-Ppe"
HeaderRPCAppID = "Rpc-Persist-Cli-Req-App-Id"
SourceValue = "lark-cli" SourceValue = "lark-cli"
TTEnvValue = "ppe_doubao_office_local"
UsePPEValue = "1"
RPCAppID = "497858"
HeaderUserAgent = "User-Agent" HeaderUserAgent = "User-Agent"
@@ -81,9 +75,6 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version) h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue()) h.Set(HeaderUserAgent, UserAgentValue())
h.Set(HeaderTTEnv, TTEnvValue)
h.Set(HeaderUsePPE, UsePPEValue)
h.Set(HeaderRPCAppID, RPCAppID)
if v := AgentTraceValue(); v != "" { if v := AgentTraceValue(); v != "" {
h.Set(HeaderAgentTrace, v) h.Set(HeaderAgentTrace, v)
} }

View File

@@ -256,26 +256,13 @@ func TestBaseSecurityHeaders_IncludesBuildHeader(t *testing.T) {
func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) { func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
h := BaseSecurityHeaders() h := BaseSecurityHeaders()
for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent, HeaderTTEnv, HeaderUsePPE, HeaderRPCAppID} { for _, key := range []string{HeaderSource, HeaderVersion, HeaderBuild, HeaderUserAgent} {
if h.Get(key) == "" { if h.Get(key) == "" {
t.Errorf("BaseSecurityHeaders missing %s", key) t.Errorf("BaseSecurityHeaders missing %s", key)
} }
} }
} }
func TestBaseSecurityHeaders_IncludesPersistentRequestHeaders(t *testing.T) {
h := BaseSecurityHeaders()
if got := h.Get(HeaderTTEnv); got != TTEnvValue {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderTTEnv, got, TTEnvValue)
}
if got := h.Get(HeaderUsePPE); got != UsePPEValue {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderUsePPE, got, UsePPEValue)
}
if got := h.Get(HeaderRPCAppID); got != RPCAppID {
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderRPCAppID, got, RPCAppID)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace // AgentTraceValue / HeaderAgentTrace
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -4,7 +4,9 @@
package credential package credential
import ( import (
"bytes"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -164,9 +166,42 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
if err != nil { if err != nil {
return nil, err return nil, err
} }
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret) ep := core.ResolveEndpoints(acct.Brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": acct.AppID,
"app_secret": acct.AppSecret,
})
if err != nil {
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &TokenResult{Token: token}, nil req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
}
return &TokenResult{Token: result.TenantAccessToken}, nil
} }

View File

@@ -1,70 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/larksuite/cli/internal/core"
)
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
// given credentials. It does not read configuration or keychain, so callers
// that already hold plaintext credentials (e.g. the post-`config init` probe)
// can validate them without a second keychain round-trip.
//
// A non-zero TAT response code means the server inspected the payload and
// rejected the credentials; FetchTAT returns the canonical typed error from
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
// every token-resolving command) produces, so callers see one consistent
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
// credential rejection apart from upstream/transport noise.
//
// The caller owns the context timeout.
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
ep := core.ResolveEndpoints(brand)
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
body, err := json.Marshal(map[string]string{
"app_id": appID,
"app_secret": appSecret,
})
if err != nil {
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
}
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
TenantAccessToken string `json:"tenant_access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to parse TAT response: %w", err)
}
if result.Code != 0 {
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
}
return result.TenantAccessToken, nil
}

View File

@@ -1,237 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package credential
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// stubRoundTripper lets us assert request shape and return canned responses.
type stubRoundTripper struct {
gotReq *http.Request
gotBody string
respCode int
respBody string
err error
}
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
s.gotReq = req
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
s.gotBody = string(b)
}
if s.err != nil {
return nil, s.err
}
return &http.Response{
StatusCode: s.respCode,
Body: io.NopCloser(strings.NewReader(s.respBody)),
Header: make(http.Header),
}, nil
}
func TestFetchTAT_Success(t *testing.T) {
rt := &stubRoundTripper{
respCode: 200,
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if token != "t-abc" {
t.Errorf("token = %q, want t-abc", token)
}
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
t.Errorf("url = %s", rt.gotReq.URL.String())
}
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
t.Errorf("request body missing credentials: %s", rt.gotBody)
}
}
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
// typed error doResolveTAT (and thus every token-resolving command) returns.
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
hc := &http.Client{Transport: rt}
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 10003")
}
if token != "" {
t.Errorf("token = %q, want empty", token)
}
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Category != errs.CategoryConfig {
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient {
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
}
if cfgErr.Code != 10003 {
t.Errorf("Code = %d, want 10003", cfgErr.Code)
}
}
// 10014 ("app secret invalid") — the most common real-world rejection (real
// app_id + wrong secret) — is globally mapped in codemeta to
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
}
}
// Any non-zero body code is a deterministic server-side rejection, so it
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
// caller still surfaces it rather than silently swallowing.
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error for code 99999")
}
if !errs.IsTyped(err) {
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
}
}
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
// silent.
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
for _, code := range []int{401, 403, 500, 503} {
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatalf("HTTP %d: expected error", code)
}
if errs.IsTyped(err) {
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
}
}
}
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
sentinel := errors.New("network down")
rt := &stubRoundTripper{err: sentinel}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected error")
}
if errs.IsTyped(err) {
t.Errorf("transport error must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, sentinel) {
t.Errorf("error chain missing sentinel: %v", err)
}
}
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
hc := &http.Client{Transport: rt}
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
if err == nil {
t.Fatal("expected parse error")
}
if errs.IsTyped(err) {
t.Errorf("parse error must be UNTYPED, got typed %T", err)
}
}
func TestFetchTAT_BrandRouting(t *testing.T) {
tests := []struct {
brand core.LarkBrand
wantURL string
}{
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
}
for _, tc := range tests {
t.Run(string(tc.brand), func(t *testing.T) {
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
hc := &http.Client{Transport: rt}
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
t.Fatal(err)
}
if got := rt.gotReq.URL.String(); got != tc.wantURL {
t.Errorf("url = %s, want %s", got, tc.wantURL)
}
})
}
}
func TestFetchTAT_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
rt := &urlRewriteRT{base: srv.URL}
hc := &http.Client{Transport: rt}
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-canceled
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
if err == nil {
t.Fatal("expected error for canceled context")
}
if errs.IsTyped(err) {
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("error chain missing context.Canceled: %v", err)
}
}
// urlRewriteRT forwards requests to a fixed base URL (test server).
type urlRewriteRT struct{ base string }
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
newURL := r.base + req.URL.Path
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
if err != nil {
return nil, err
}
req2.Header = req.Header
return http.DefaultTransport.RoundTrip(req2)
}

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

@@ -13,7 +13,7 @@ const (
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE" CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
// Sidecar proxy (auth proxy mode) // Sidecar proxy (auth proxy mode)
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384" CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
// Content safety scanning mode // Content safety scanning mode

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// driveCodeMeta holds drive/docs-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 driveCodeMeta = map[int]CodeMeta{
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
}
func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -1,43 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
// Each case traces to repo evidence (see codemeta_drive.go comments).
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
// 1061044: upload with a nonexistent parent folder token. The drive E2E
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
// producer via a nonexistent parent folder → referenced resource missing.
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("code %d not registered in codeMeta", tc.code)
}
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -130,7 +130,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin
if !opts.Quiet { if !opts.Quiet {
fmt.Fprintln(errOut, listeningText(opts)) fmt.Fprintln(errOut, listeningText(opts))
if !opts.IsTTY { 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" return "signal"
} }
func stopHintText(opts Options) string { func stopHintText() 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."
}
return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " + return "[event] to stop gracefully: send SIGTERM (kill <pid>) or close stdin. " +
"Avoid kill -9 — it skips cleanup and may leak server-side subscriptions." "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. // AI-facing contract: must name "kill -9" + "cleanup" so agents parsing stderr are steered away from SIGKILL.
func TestStopHintText_Unbounded(t *testing.T) { func TestStopHintText_Content(t *testing.T) {
got := stopHintText(Options{}) got := stopHintText()
mustContain := []string{"SIGTERM", "kill -9", "cleanup", "close stdin"}
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"} mustContain := []string{"SIGTERM", "kill -9", "cleanup"}
for _, s := range mustContain { for _, s := range mustContain {
if !bytes.Contains([]byte(got), []byte(s)) { if !bytes.Contains([]byte(got), []byte(s)) {
t.Errorf("stopHintText(bounded) missing %q; got %q", s, got) t.Errorf("stopHintText 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)
} }
} }
} }

View File

@@ -170,28 +170,6 @@ func ErrBare(code int) *ExitError {
return &ExitError{Code: code} return &ExitError{Code: code}
} }
// PartialFailureError is the exit signal for a batch / multi-status command that
// has already written an ok:false result envelope to stdout. The per-item
// outcomes are the primary, machine-readable output and live on stdout, so the
// dispatcher sets only the exit code and writes nothing to stderr.
//
// It is deliberately distinct from ErrBare (the predicate silent-exit signal)
// so the predicate contract stays narrow, and from a typed *errs.XxxError
// (which owns the stderr error envelope): a partial failure is a result, not an
// error envelope.
type PartialFailureError struct {
Code int
}
func (e *PartialFailureError) Error() string {
return fmt.Sprintf("partial failure (exit %d)", e.Code)
}
// PartialFailure builds the partial-failure exit signal with the given code.
func PartialFailure(code int) *PartialFailureError {
return &PartialFailureError{Code: code}
}
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error. // WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
// Each typed error owns its wire shape via its own struct tags: Problem fields // Each typed error owns its wire shape via its own struct tags: Problem fields
// are promoted to the top level through embedding, and extension fields // are promoted to the top level through embedding, and extension fields

View File

@@ -61,10 +61,6 @@ func ExitCodeOf(err error) int {
if _, ok := errs.ProblemOf(err); ok { if _, ok := errs.ProblemOf(err); ok {
return ExitCodeForCategory(errs.CategoryOf(err)) return ExitCodeForCategory(errs.CategoryOf(err))
} }
var pfErr *PartialFailureError
if errors.As(err, &pfErr) {
return pfErr.Code
}
var exitErr *ExitError var exitErr *ExitError
if errors.As(err, &exitErr) { if errors.As(err, &exitErr) {
return exitErr.Code return exitErr.Code

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,19 +30,6 @@ lark-cli-harness:dev@0.1.0
} }
} }
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
input := `Available Skills
│ lark-calendar
│ official-shared
│ bad/name
`
got := ParseSkillsList(input)
want := []string{"lark-calendar", "official-shared"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) { func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills input := `Global Skills
@@ -80,86 +67,6 @@ func TestParseGlobalSkillsListWithANSI(t *testing.T) {
} }
} }
func TestParseGlobalSkillsListWithIndentedGroupedRows(t *testing.T) {
input := `Global Skills
General
lark-apps ~/.agents/skills/lark-apps
lark-base ~/.agents/skills/lark-base
`
got := ParseSkillsList(input)
want := []string{"lark-apps", "lark-base"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (indented Global Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSON(t *testing.T) {
input := `[
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":"lark-mail@1.2.3","path":"/Users/example/.agents/skills/lark-mail","scope":"global","agents":["Codex"]},
{"name":"lark-calendar","path":"/Users/example/.agents/skills/lark-calendar","scope":"global","agents":["Codex"]},
{"name":" lark-base ","path":"/Users/example/.agents/skills/lark-base","scope":"global","agents":["Codex"]},
{"name":""},
{"name":" "},
{"name":"bad skill"}
]`
got := ParseGlobalSkillsJSON(input)
want := []string{"lark-base", "lark-calendar", "lark-mail@1.2.3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseGlobalSkillsJSON() = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`{"name":"lark-calendar"}`,
`[]`,
} {
if got := ParseGlobalSkillsJSON(input); len(got) != 0 {
t.Fatalf("ParseGlobalSkillsJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
input := `{
"skills": [
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
{"name":" lark-base ","description":"Base","files":[]},
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
{"name":"","description":"empty","files":["SKILL.md"]}
]
}`
got, err := ParseOfficialSkillsIndexJSON(input)
if err != nil {
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
}
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
}
}
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`[{"name":"lark-calendar"}]`,
`{"name":"lark-calendar"}`,
`{"skills":[]}`,
`{"skills":[{"name":"bad skill"}]}`,
} {
got, err := ParseOfficialSkillsIndexJSON(input)
if err == nil && len(got) != 0 {
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) { func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}} previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{ got := PlanSync(SyncInput{
@@ -206,22 +113,14 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
} }
type fakeSkillsRunner struct { type fakeSkillsRunner struct {
officialIndexOut string
officialOut string officialOut string
globalJSONOut string
globalOut string globalOut string
officialIndexErr error
officialErr error officialErr error
globalJSONErr error
globalErr error globalErr error
installErr error installErr error
installAllErr error installAllErr error
installed [][]string installed [][]string
installedAll int installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
} }
func officialSkillsOutput(names ...string) string { func officialSkillsOutput(names ...string) string {
@@ -235,19 +134,6 @@ func officialSkillsOutput(names ...string) string {
return b.String() return b.String()
} }
func officialSkillsIndexOutput(names ...string) string {
var b strings.Builder
b.WriteString(`{"skills":[`)
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
}
b.WriteString(`]}`)
return b.String()
}
func globalSkillsOutput(names ...string) string { func globalSkillsOutput(names ...string) string {
var b strings.Builder var b strings.Builder
b.WriteString("Global Skills\n\n") b.WriteString("Global Skills\n\n")
@@ -260,45 +146,14 @@ func globalSkillsOutput(names ...string) string {
return b.String() return b.String()
} }
func globalSkillsJSONOutput(names ...string) string {
var b strings.Builder
b.WriteString("[")
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"path":"/Users/example/.agents/skills/%s","scope":"global","agents":["Codex"]}`, name, name)
}
b.WriteString("]")
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
f.listedIndex++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialIndexOut)
r.Err = f.officialIndexErr
return r
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult { func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
f.listedOfficial++
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut) r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr r.Err = f.officialErr
return r return r
} }
func (f *fakeSkillsRunner) ListGlobalSkillsJSON() *selfupdate.NpmResult {
f.listedGlobalJSON++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalJSONOut)
r.Err = f.globalJSONErr
return r
}
func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult { func (f *fakeSkillsRunner) ListGlobalSkills() *selfupdate.NpmResult {
f.listedGlobalText++
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.globalOut) r.Stdout.WriteString(f.globalOut)
r.Err = f.globalErr r.Err = f.globalErr
@@ -331,10 +186,8 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
} }
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"), globalOut: globalSkillsOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
} }
result := SyncSkills(SyncOptions{ result := SyncSkills(SyncOptions{
Version: "1.0.33", Version: "1.0.33",
@@ -346,12 +199,6 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err) t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
} }
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"}) assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-new"})
if runner.listedGlobalJSON != 1 {
t.Fatalf("listedGlobalJSON = %d, want 1", runner.listedGlobalJSON)
}
if runner.listedGlobalText != 0 {
t.Fatalf("listedGlobalText = %d, want 0 when JSON list succeeds", runner.listedGlobalText)
}
state, readable, err := ReadState() state, readable, err := ReadState()
if err != nil || !readable { if err != nil || !readable {
@@ -366,117 +213,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
} }
} }
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-should-not-be-used"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
if runner.listedIndex != 1 {
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
}
if runner.listedOfficial != 0 {
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
}
}
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
}
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
}
}
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) { func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"), officialErr: fmt.Errorf("list failed"),
installAllErr: nil, installAllErr: nil,
} }
@@ -506,7 +246,6 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"), officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"), installAllErr: fmt.Errorf("full install failed"),
} }
@@ -523,120 +262,55 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
} }
} }
func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) { func TestSyncSkills_GlobalListFailureDegradesToColdStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"), globalErr: fmt.Errorf("global list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil { if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err) t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
} }
if result.Action != "synced" { if result.Action != "synced" {
t.Fatalf("SyncSkills() action = %q, want synced", result.Action) t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
} }
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
if runner.listedGlobalJSON != 1 || runner.listedGlobalText != 1 { assertStrings(t, result.SkippedDeleted, []string{})
t.Fatalf("listed JSON/text = %d/%d, want 1/1", runner.listedGlobalJSON, runner.listedGlobalText)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
} }
func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) { func TestSyncSkills_ParseEmptyGlobalListWithNonEmptyStdoutDegradesToColdStart(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
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", globalOut: "Some unrecognized output format\n",
} }
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now}) result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" { if result.Err != nil {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action) t.Fatalf("SyncSkills() err = %v, want nil (degraded to cold start)", result.Err)
} }
if len(runner.installed) != 0 { if result.Action != "synced" {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed) t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
} }
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
}
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
if err := WriteState(SkillsState{
Version: "1.0.30",
OfficialSkills: []string{"lark-calendar", "lark-mail"},
UpdatedAt: "2026-05-18T00:00:00Z",
}); err != nil {
t.Fatal(err)
}
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
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 len(runner.installed) != 0 {
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1 (fallback triggered)", runner.installedAll)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"}) assertStrings(t, result.Updated, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.Added, []string{"lark-calendar", "lark-mail"})
assertStrings(t, result.SkippedDeleted, []string{}) assertStrings(t, result.SkippedDeleted, []string{})
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0 (no fallback)", runner.installedAll)
}
if len(runner.installed) != 1 {
t.Fatalf("installed = %d calls, want 1 (incremental)", len(runner.installed))
}
} }
func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) { func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"), installErr: fmt.Errorf("incremental boom"),
installAllErr: nil, installAllErr: nil,
@@ -667,9 +341,7 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"), installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"), installAllErr: fmt.Errorf("full install boom"),
@@ -701,7 +373,6 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n", officialOut: "Some unrecognized output format\n",
installAllErr: nil, installAllErr: nil,
} }
@@ -719,7 +390,6 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n", officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"), installAllErr: fmt.Errorf("full install failed"),
} }
@@ -744,7 +414,6 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n", officialOut: "Some unrecognized output format\n",
installAllErr: nil, installAllErr: nil,
} }
@@ -770,9 +439,7 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"), installErr: fmt.Errorf("incremental boom"),
installAllErr: nil, installAllErr: nil,
@@ -796,9 +463,7 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"), installErr: fmt.Errorf("incremental boom"),
installAllErr: nil, installAllErr: nil,
@@ -821,7 +486,6 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{ runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"), officialErr: fmt.Errorf("list failed"),
installAllErr: nil, installAllErr: nil,
} }
@@ -840,9 +504,7 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
} }
runner2 := &fakeSkillsRunner{ runner2 := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"), officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"), globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
} }
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now}) result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})

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

@@ -1,155 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// migratedEnvelopePaths lists the source-tree prefixes that have been migrated
// to the typed errs.* taxonomy. On these paths, constructing a legacy
// output.ExitError / output.ErrDetail envelope literal directly is forbidden —
// 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
// legacy ExitError / ErrDetail envelope types. The rule resolves whatever local
// name (default or alias) this path is bound to in each file, so an aliased
// import cannot bypass the check.
const legacyOutputImportPath = "github.com/larksuite/cli/internal/output"
// CheckNoLegacyEnvelopeLiteral flags direct construction of legacy
// output.ExitError / output.ErrDetail composite literals on migrated paths.
// forbidigo can ban identifiers but not composite literals, so this AST rule
// covers the gap left after a path is migrated to typed errs.* errors.
//
// Path-scoped to migratedEnvelopePaths (mirrors how CheckProblemEmbed restricts
// by path); skips _test.go fixtures. output.ErrBare(...) is a CallExpr, not a
// CompositeLit, so the predicate exit-signal helper is naturally not flagged.
func CheckNoLegacyEnvelopeLiteral(path, src string) []Violation {
if !isMigratedEnvelopePath(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
}
// Resolve the local name(s) bound to the legacy output import path. A file
// may bind it as the default `output`, an alias (`legacy "...output"`), or a
// dot-import (qualifier becomes ""), in which case ExitError/ErrDetail appear
// as bare unqualified idents.
localNames, dotImported := resolveLegacyOutputNames(file)
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
if name, ok := legacyEnvelopeTypeName(lit.Type, localNames, dotImported); ok {
out = append(out, Violation{
Rule: "no_legacy_envelope_literal",
Action: ActionReject,
File: path,
Line: fset.Position(lit.Pos()).Line,
Message: "direct construction of legacy output." + name + " is forbidden on migrated paths; return a typed errs.* error (output.ErrBare remains allowed for predicate exit signals)",
Suggestion: "replace the &output." + name + "{...} literal with a typed errs.* constructor " +
"(e.g. errs.NewValidationError / errs.NewAPIError / errs.NewNetworkError)",
})
}
return true
})
return out
}
// isMigratedEnvelopePath reports whether path falls under any migrated path
// prefix in migratedEnvelopePaths.
func isMigratedEnvelopePath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
for _, prefix := range migratedEnvelopePaths {
if strings.HasPrefix(p, prefix) || strings.Contains(p, "/"+prefix) {
return true
}
}
return false
}
// resolveLegacyOutputNames walks the file's import declarations and returns the
// set of local names bound to legacyOutputImportPath, plus whether the path was
// dot-imported. Default imports bind the package's own name ("output"); aliased
// imports bind the alias; dot-imports bind names into the file scope.
func resolveLegacyOutputNames(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 != legacyOutputImportPath {
continue
}
switch {
case imp.Name == nil:
// Default import: local name is the package name "output".
names["output"] = struct{}{}
case imp.Name.Name == ".":
dotImported = true
case imp.Name.Name == "_":
// Blank import cannot reference the types; ignore.
default:
names[imp.Name.Name] = struct{}{}
}
}
return names, dotImported
}
// legacyEnvelopeTypeName reports whether a composite-literal Type names the
// legacy ExitError / ErrDetail envelope and returns the bare type name. It
// matches a qualified selector (pkg.ExitError) when pkg is one of the resolved
// local names for the legacy output import, and — when the package was
// dot-imported — also matches a bare unqualified ExitError / ErrDetail ident.
func legacyEnvelopeTypeName(expr ast.Expr, localNames map[string]struct{}, dotImported bool) (string, bool) {
if sel, ok := expr.(*ast.SelectorExpr); ok {
x, ok := sel.X.(*ast.Ident)
if !ok || sel.Sel == nil {
return "", false
}
if _, bound := localNames[x.Name]; !bound {
return "", false
}
return matchLegacyEnvelopeName(sel.Sel.Name)
}
if dotImported {
if ident, ok := expr.(*ast.Ident); ok {
return matchLegacyEnvelopeName(ident.Name)
}
}
return "", false
}
// matchLegacyEnvelopeName returns the name when it is one of the legacy
// envelope type names.
func matchLegacyEnvelopeName(name string) (string, bool) {
switch name {
case "ExitError", "ErrDetail":
return name, true
}
return "", false
}

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// CheckNoLegacyRuntimeAPICall flags calls to the runtime's legacy
// auto-classifying API helpers (CallAPI / DoAPIJSON / DoAPIJSONWithLogID) on
// migrated paths. Those helpers route failures through common.HandleApiResult /
// doAPIJSON, which emit a legacy output.ExitError "api_error" envelope and
// downgrade an already-typed network / auth boundary error into an API error.
// 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
// runtime.DoAPI + errclass.BuildAPIError directly, so failures classify into
// typed errs.* errors.
//
// Path-scoped to migratedEnvelopePaths; skips _test.go fixtures. A typed wrapper
// like driveCallAPI is an unqualified call (*ast.Ident), not a selector, so it
// is not matched. runtime.DoAPI / runtime.RawAPI are intentionally not listed:
// they return the raw response for the caller to classify and do not emit a
// legacy envelope themselves.
func CheckNoLegacyRuntimeAPICall(path, src string) []Violation {
if !isMigratedEnvelopePath(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
}
var out []Violation
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel == nil {
return true
}
if name, ok := matchLegacyRuntimeAPIMethod(sel.Sel.Name); ok {
out = append(out, Violation{
Rule: "no_legacy_runtime_api_call",
Action: ActionReject,
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 " +
"so failures classify into typed errs.* errors",
})
}
return true
})
return out
}
// matchLegacyRuntimeAPIMethod returns the name when it is one of the runtime's
// legacy auto-classifying API helper methods.
func matchLegacyRuntimeAPIMethod(name string) (string, bool) {
switch name {
case "CallAPI", "DoAPIJSON", "DoAPIJSONWithLogID":
return name, true
}
return "", false
}

View File

@@ -593,486 +593,3 @@ func FooRegisterServiceMapBar(name string, _ interface{}) {}
t.Errorf("message must name the offending call: %s", v[0].Message) t.Errorf("message must name the offending call: %s", v[0].Message)
} }
} }
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.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, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
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
import "github.com/larksuite/cli/internal/output"
func boom() *output.ErrDetail {
return &output.ErrDetail{Code: 7}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "ErrDetail") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return output.ErrBare(output.ExitAPI)
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("ErrBare call should pass, got: %+v", v)
}
}
func TestCheckNoLegacyEnvelopeLiteral_IgnoresNonMigratedPath(t *testing.T) {
// Same offending literal, but outside the migrated path set → not flagged.
src := `package other
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/contact/foo.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path should pass, got: %+v", v)
}
}
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
if len(v) != 0 {
t.Errorf("_test.go file should be skipped, got: %+v", v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
// import of internal/output cannot bypass the rule: the qualifier is resolved
// from the import declaration, not matched against the literal string "output".
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
src := `package drive
import legacy "github.com/larksuite/cli/internal/output"
func boom() error {
return &legacy.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for aliased import, 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)
}
}
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
// regression where resolving by import path accidentally drops the default
// (non-aliased) `output` case.
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
src := `package drive
import "github.com/larksuite/cli/internal/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
// a CallExpr, not a composite literal — even under an alias it must not fire.
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
src := `package drive
import legacy "github.com/larksuite/cli/internal/output"
func boom() error {
return legacy.ErrBare(legacy.ExitAPI)
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("ErrBare call should pass, got: %+v", v)
}
}
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
// the composite literal.
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
src := `package drive
import . "github.com/larksuite/cli/internal/output"
func boom() error {
return &ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "ExitError") {
t.Errorf("message should name the legacy type: %s", v[0].Message)
}
}
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
// selector on an unrelated package (not the legacy output import path) must not
// trigger a false positive.
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
src := `package drive
import "example.com/other/output"
func boom() error {
return &output.ExitError{Code: 1}
}
`
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
if len(v) != 0 {
t.Errorf("unrelated package selector must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.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_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
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
t.Errorf("message should name the legacy method: %s", v[0].Message)
}
}
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
if len(v) != 0 {
t.Errorf("typed wrapper call must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
// RawAPI / DoAPI return the raw response for the caller to classify and do
// not emit a legacy envelope — they are not banned.
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
_, err := runtime.DoAPI(nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
if len(v) != 0 {
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_IgnoresNonMigratedPath(t *testing.T) {
src := `package contact
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/contact/contact_get.go", src)
if len(v) != 0 {
t.Errorf("non-migrated path must not fire, got: %+v", v)
}
}
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
src := `package drive
func boom(runtime *common.RuntimeContext) error {
_, err := runtime.CallAPI("POST", "/x", nil, nil)
return err
}
`
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
if len(v) != 0 {
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

@@ -106,9 +106,6 @@ func ScanRepo(root string) ([]Violation, error) {
all = append(all, CheckNoRegistrar(rel, string(src))...) all = append(all, CheckNoRegistrar(rel, string(src))...)
all = append(all, CheckAdHocSubtype(rel, string(src))...) all = append(all, CheckAdHocSubtype(rel, string(src))...)
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...) 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. // Typed-error invariants — self-scope to errs/ + classify.go.
all = append(all, CheckNilSafeError(rel, string(src))...) all = append(all, CheckNilSafeError(rel, string(src))...)
all = append(all, CheckUnwrapSymmetry(rel, string(src))...) all = append(all, CheckUnwrapSymmetry(rel, string(src))...)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,12 +32,12 @@ var BaseDataQuery = common.Shortcut{
dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl")))) dec := json.NewDecoder(bytes.NewReader([]byte(runtime.Str("dsl"))))
dec.UseNumber() dec.UseNumber()
if err := dec.Decode(&dsl); err != nil { if err := dec.Decode(&dsl); err != nil {
return baseFlagErrorf("--dsl invalid JSON: %v", err) return common.FlagErrorf("--dsl invalid JSON: %v", err)
} }
_, hasDim := dsl["dimensions"] _, hasDim := dsl["dimensions"]
_, hasMeas := dsl["measures"] _, hasMeas := dsl["measures"]
if !hasDim && !hasMeas { if !hasDim && !hasMeas {
return baseFlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'") return common.FlagErrorf("--dsl must contain at least one of 'dimensions' or 'measures'")
} }
return nil return nil
}, },

View File

@@ -32,29 +32,6 @@ func TestDryRunTableOps(t *testing.T) {
assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1") assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1")
} }
func TestDryRunBaseBlockOps(t *testing.T) {
ctx := context.Background()
listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list")
listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`)
createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`)
moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`)
moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`)
renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil)
assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`)
assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1")
}
func TestDryRunFieldOps(t *testing.T) { func TestDryRunFieldOps(t *testing.T) {
ctx := context.Background() ctx := context.Background()
@@ -94,29 +71,6 @@ func TestDryRunRecordOps(t *testing.T) {
) )
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age") assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
filteredListRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"filter-json": `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
"sort-json": `[{"field":"Due","desc":true}]`,
},
nil,
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(
t,
dryRunRecordList(ctx, filteredListRT),
"GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records",
"limit=20",
"filter=%7B",
"Status",
"Todo",
"sort=%5B",
"Due",
)
commaFieldRT := newBaseTestRuntimeWithArrays( commaFieldRT := newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"}, map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-id": {"A,B", "C"}}, map[string][]string{"field-id": {"A,B", "C"}},
@@ -145,33 +99,6 @@ func TestDryRunRecordOps(t *testing.T) {
`"limit":500`, `"limit":500`,
) )
searchFlagRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"keyword": "Alice",
"view-id": "viw_1",
"filter-json": `{"logic":"and","conditions":[["Status","!=","Done"]]}`,
"sort-json": `[{"field":"Updated At","desc":true}]`,
},
map[string][]string{
"search-field": {"Name"},
"field-id": {"Name", "Status"},
},
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(
t,
dryRunRecordSearch(ctx, searchFlagRT),
"POST /open-apis/base/v3/bases/app_x/tables/tbl_1/records/search",
`"keyword":"Alice"`,
`"search_fields":["Name"]`,
`"select_fields":["Name","Status"]`,
`"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`,
`"sort":[{"desc":true,"field":"Updated At"}]`,
)
upsertCreateRT := newBaseTestRuntime( upsertCreateRT := newBaseTestRuntime(
map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`}, map[string]string{"base-token": "app_x", "table-id": "tbl_1", "json": `{"Name":"A"}`},
nil, nil, nil, nil,

View File

@@ -4,13 +4,9 @@
package base package base
import ( import (
"errors"
"fmt"
"strings" "strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/util"
) )
@@ -28,196 +24,74 @@ func handleBaseAPIResult(result interface{}, err error, action string) (map[stri
// structured ErrAPI, with server-provided message/hint promoted to the top level. // structured ErrAPI, with server-provided message/hint promoted to the top level.
func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) { func handleBaseAPIResultAny(result interface{}, err error, action string) (interface{}, error) {
if err != nil { if err != nil {
return nil, baseAPIBoundaryError(err, action) return nil, output.Errorf(output.ExitAPI, "api_error", "%s: %s", action, err)
} }
resultMap, ok := result.(map[string]interface{}) resultMap, _ := result.(map[string]interface{})
if !ok || resultMap == nil { code, _ := util.ToFloat64(resultMap["code"])
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API returned a malformed response envelope", action)
}
if _, exists := resultMap["code"]; !exists {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response is missing code", action)
}
code, numeric := util.ToFloat64(resultMap["code"])
if !numeric {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: API response code is not numeric", action)
}
if code == 0 { if code == 0 {
return resultMap["data"], nil return resultMap["data"], nil
} }
return nil, baseAPIErrorFromResult(resultMap, errclass.ClassifyContext{}) larkCode := int(code)
msg := extractDataErrorMessage(resultMap)
if strings.TrimSpace(msg) == "" {
msg, _ = resultMap["msg"].(string)
} }
// baseFlagErrorf marks flag-usage failures; it shares baseValidationErrorf's detail := extractErrorDetail(resultMap)
// typed envelope and exists so call sites read as flag rejections. apiErr := output.ErrAPI(larkCode, msg, detail)
func baseFlagErrorf(format string, args ...any) error {
return baseValidationErrorf(format, args...)
}
func baseValidationErrorf(format string, args ...any) error {
msg := fmt.Sprintf(format, args...)
err := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", msg)
if params := flagParams(msg); len(params) > 0 {
err = err.WithParam(params[0].Name).WithParams(params...)
}
if cause := firstErrorArg(args); cause != nil {
err = err.WithCause(cause)
}
return err
}
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:]
}
return params
}
func firstErrorArg(args []any) error {
for _, arg := range args {
if err, ok := arg.(error); ok {
return err
}
}
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
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
}
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)
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot create file: %s", err).WithCause(err)
}
}
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) hint := extractErrorHint(resultMap)
if logID := extractBaseErrorLogID(resultMap); logID != "" { if apiErr.Detail != nil && apiErr.Detail.Hint == "" && hint != "" {
resultMap["log_id"] = logID apiErr.Detail.Hint = hint
} }
err := errclass.BuildAPIError(resultMap, cc) if apiErr.Detail != nil {
if err == nil { apiErr.Detail.Detail = cleanEmptyBaseErrorDetail(detail)
}
return nil, apiErr
}
func cleanEmptyBaseErrorDetail(detail interface{}) interface{} {
detailMap, ok := detail.(map[string]interface{})
if !ok {
return nil return nil
} }
if p, ok := errs.ProblemOf(err); ok && hint != "" { for key, value := range detailMap {
p.Hint = hint if value == nil {
delete(detailMap, key)
} }
return err }
if len(detailMap) == 0 {
return nil
}
return detailMap
} }
func enrichBaseAPIErrorFromBody(err error, body []byte, cc errclass.ClassifyContext) error { func extractErrorDetail(resultMap map[string]interface{}) interface{} {
if _, ok := errs.ProblemOf(err); !ok { if detail, ok := nonNilMapValue(resultMap, "error"); ok {
return err return detail
}
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{}) data, _ := resultMap["data"].(map[string]interface{})
if detail, ok := data["error"].(map[string]interface{}); ok { if detail, ok := nonNilMapValue(data, "error"); ok {
for _, key := range []string{"log_id", "logid"} { return detail
if logID, _ := detail[key].(string); strings.TrimSpace(logID) != "" {
return strings.TrimSpace(logID)
} }
return nil
} }
func nonNilMapValue(src map[string]interface{}, key string) (interface{}, bool) {
if src == nil {
return nil, false
}
value, ok := src[key]
if !ok {
return nil, false
}
switch value.(type) {
case nil:
return nil, false
default:
return value, true
} }
return ""
} }
func extractErrorHint(resultMap map[string]interface{}) string { func extractErrorHint(resultMap map[string]interface{}) string {

View File

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

View File

@@ -18,7 +18,6 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -412,167 +411,6 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf
return body return body
} }
func TestBaseBlockExecuteShortcuts(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
listStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"blocks": []interface{}{
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
},
"total": 2,
},
},
}
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
},
}
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
},
}
renameStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
},
}
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"block_id": "blk_doc"},
},
}
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
reg.Register(stub)
}
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
t.Fatalf("list err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
t.Fatalf("list stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
t.Fatalf("list body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
t.Fatalf("create err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("create stdout=%s", got)
}
createBody := decodeCapturedJSONBody(t, createStub)
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
t.Fatalf("create body=%#v", createBody)
}
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
t.Fatalf("move err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
t.Fatalf("move stdout=%s", got)
}
moveBody := decodeCapturedJSONBody(t, moveStub)
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
t.Fatalf("move body=%#v", moveBody)
}
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
t.Fatalf("rename err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
t.Fatalf("rename stdout=%s", got)
}
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
t.Fatalf("rename body=%#v", body)
}
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
t.Fatalf("delete err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
t.Fatalf("delete stdout=%s", got)
}
}
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tests := []struct {
name string
shortcut common.Shortcut
args []string
params []string
}{
{
name: "create blank name",
shortcut: BaseBaseBlockCreate,
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
params: []string{"--name"},
},
{
name: "move conflicting sibling anchors",
shortcut: BaseBaseBlockMove,
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
params: []string{"--before-id", "--after-id"},
},
{
name: "rename blank name",
shortcut: BaseBaseBlockRename,
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
params: []string{"--name"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected ValidationError, got %T %v", err, err)
}
if validationErr.Param != tt.params[0] {
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
}
if len(validationErr.Params) != len(tt.params) {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
for i, param := range tt.params {
if validationErr.Params[i].Name != param {
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
}
if validationErr.Params[i].Reason == "" {
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
}
}
})
}
}
func TestBaseHistoryExecute(t *testing.T) { func TestBaseHistoryExecute(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
@@ -934,7 +772,7 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
Method: "GET", Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables", URL: "/open-apis/base/v3/bases/app_x/tables",
Status: 404, Status: 404,
RawBody: []byte("404 page not found"), Body: "404 page not found",
Headers: map[string][]string{ Headers: map[string][]string{
"Content-Type": {"text/plain"}, "Content-Type": {"text/plain"},
}, },
@@ -1136,7 +974,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"+record-search", "+record-search",
"--base-token", "app_x", "--base-token", "app_x",
"--table-id", "tbl_x", "--table-id", "tbl_x",
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`, "--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
"--format", "json", "--format", "json",
}, },
factory, factory,
@@ -1152,121 +990,12 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
!strings.Contains(body, `"keyword":"Created"`) || !strings.Contains(body, `"keyword":"Created"`) ||
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) || !strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) || !strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
!strings.Contains(body, `"offset":0`) || !strings.Contains(body, `"offset":0`) ||
!strings.Contains(body, `"limit":2`) { !strings.Contains(body, `"limit":2`) {
t.Fatalf("captured body=%s", body) t.Fatalf("captured body=%s", body)
} }
}) })
t.Run("search with flag filter sort and projection", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title", "Status"},
"field_id_list": []interface{}{"fld_title", "fld_status"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--keyword", "Created",
"--search-field", "Title",
"--field-id", "Title",
"--field-id", "Status",
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
"--limit", "20",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
}
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
t.Fatalf("captured body=%#v", body)
}
filter := body["filter"].(map[string]interface{})
if filter["logic"] != "and" {
t.Fatalf("filter=%#v", filter)
}
conditions := filter["conditions"].([]interface{})
if len(conditions) != 2 {
t.Fatalf("conditions=%#v", conditions)
}
sortConfig := body["sort"].([]interface{})
if len(sortConfig) != 2 {
t.Fatalf("sort=%#v", sortConfig)
}
firstSort := sortConfig[0].(map[string]interface{})
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
t.Fatalf("sort=%#v", sortConfig)
}
})
t.Run("search with filter json file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmp := t.TempDir()
withBaseWorkingDir(t, tmp)
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
t.Fatalf("write filter err=%v", err)
}
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"A"}},
"has_more": false,
},
},
}
reg.Register(searchStub)
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--keyword", "A",
"--search-field", "Title",
"--filter-json", "@filter.json",
"--format", "json",
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
body := string(searchStub.CapturedBody)
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
t.Fatalf("captured body=%s", body)
}
})
t.Run("search markdown format", func(t *testing.T) { t.Run("search markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
@@ -2153,9 +1882,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
if !strings.Contains(err.Error(), "exceeds 2GB limit") { if !strings.Contains(err.Error(), "exceeds 2GB limit") {
t.Fatalf("err=%v", err) t.Fatalf("err=%v", err)
} }
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
t.Fatalf("err=%v should name the offending file", err)
}
}) })
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) { t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
@@ -2325,23 +2051,6 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
} }
}) })
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
"+record-download-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--output", "../escape",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
t.Fatalf("err=%v", err)
}
})
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) { t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t) factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
@@ -2538,37 +2247,21 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--record-id", "rec_x", "--record-id", "rec_x",
"--output", "downloads", "--output", "downloads",
}, factory, stdout) }, factory, stdout)
if err == nil { if err == nil || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("err=%v", err) t.Fatalf("err=%v", err)
} }
var partialErr *output.PartialFailureError var exitErr *output.ExitError
if !errors.As(err, &partialErr) { if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected partial failure error, got %T %v", err, err) t.Fatalf("expected structured error, got %T %v", err, err)
} }
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
var envelope map[string]interface{} downloaded, _ := detail["downloaded"].([]map[string]interface{})
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { failed, _ := detail["failed"].([]map[string]interface{})
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String()) if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
} }
if envelope["ok"] != false { if detail["log_id"] != "202605270001" {
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope) t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
}
data, _ := envelope["data"].(map[string]interface{})
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("message=%q", msg)
}
downloaded, _ := data["downloaded"].([]interface{})
failed, _ := data["failed"].([]interface{})
if len(downloaded) != 1 || len(failed) != 1 {
t.Fatalf("data=%#v", data)
}
downloadedItem, _ := downloaded[0].(map[string]interface{})
failedItem, _ := failed[0].(map[string]interface{})
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
t.Fatalf("data=%#v", data)
}
if data["log_id"] != "202605270001" {
t.Fatalf("data=%#v, want log_id", data)
} }
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil { if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
t.Fatalf("expected first file to remain: %v", err) t.Fatalf("expected first file to remain: %v", err)

View File

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

View File

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

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