Compare commits

..

22 Commits

Author SHA1 Message Date
shanglei
3b6aa7dc6a perf(startup): resolve brand & strict-mode without decrypting the app secret
Building the command tree resolved brand (auth login --domain help) and
strict mode (per-command identity-flag registration via ResolveStrictMode)
through f.Config(), which decrypts the app secret. On macOS that is a
Keychain (securityd IPC) read on every invocation -- even --help/--version/
schema/completion, which never use the secret.

brand and strict mode are plain config.json fields, so route them through a
secret-free metadata path:
- credential: add (*DefaultAccountProvider).ResolveMeta and a cached
  (*CredentialProvider).ResolveMeta returning brand + SupportedIdentities,
  via an optional metaResolver capability; external providers fall back to
  ResolveAccount (they do not touch the keychain).
- cmdutil: ResolveStrictMode now uses ResolveMeta; add Factory.ConfigBrand.
- auth login help text and shortcut registration read ConfigBrand.

The app secret is still decrypted on demand when a command actually calls
the API (auth status etc. unchanged). Measured on macOS (GOMAXPROCS=1):
lark-cli --help 35.4ms -> 21.4ms (-40%); no behavior change on Linux/Windows
(local secret backends are cheap).
2026-06-10 17:46:03 +08:00
shanglei
ed63e12725 fix(registry): drop unused typedInitialized var; refresh OrderedProps doc
typedInitialized was only assigned (in resetTyped) and never read, so
staticcheck's U1000 (golangci `unused`) flagged it and would block the
lint gate. Remove the field and its assignment.

Also refresh the OrderedProps comment: the static-registry migration
removed the meta_data.json natural-order machinery, so Order is now
populated alphabetically (orderedKeys). The comment still claimed
natural-order preservation. No behavior change.
2026-06-10 14:46:18 +08:00
shanglei
761aa55cbf feat(registry): carry affordance through the typed registry
The static-meta migration modeled only the method fields it explicitly
declared, but the schema assembler still reads method["affordance"] to
build _meta.affordance. metaschema.Method had no Affordance field and
MethodToMap did not backfill it, so any method whose meta_data.json entry
carries an affordance overlay would silently lose _meta.affordance in
`schema --format json` (the embedded-JSON loader preserved it via the
untyped map). Local meta currently has zero affordance entries, so no
existing test caught it.

Model affordance in the typed registry:
- metaschema.Method gains Affordance plus Affordance/AffordanceExample types
- gen.go emits the affordance literal (static, zero-alloc)
- MethodToMap rebuilds method["affordance"] so parseAffordance keeps working

schema JSON mode reads EmbeddedSpec (static baseline via ServiceToMap),
which bypasses the remote overlay for determinism, so the remote/wire and
MapToMethod paths -- never affordance consumers -- are left untouched.

Add TestBuildMeta_AffordanceThroughTypedRegistry covering
Method -> MethodToMap -> buildMeta -> _meta.affordance.
2026-06-10 14:13:56 +08:00
shanglei
2098c3c412 perf(registry): drop embedded meta_data.json and the larkmeta build tag
The startup baseline now comes solely from the generated static Go registry
(metastatic.Registry), wired into the stub-declared Registry via a package-level
var plus an init() struct-header copy. No build tag, no committed generated
file, and zero startup allocation is preserved.

- gen.go emits a tag-free `var registryData` + `func init()` instead of a
  //go:build larkmeta top-level `var Registry`; stub.go declares Registry
  unconditionally so the package always compiles
- fetch_meta.py regenerates the static registry after fetching, so every build
  and CI step that fetches also produces it (no separate gen step, no CI change)
- remove the //go:embed meta_data.json baseline and the JSON parse fallback;
  meta_data.json is now only the build-time input to the generator
- EmbeddedSpec/EmbeddedServiceNames read the static baseline; drop the schema
  key-order machinery so envelope field order is alphabetical (JSON Schema
  property order is not semantic; parameterOrder for positional args is intact)
- drop -tags larkmeta from Makefile, .goreleaser.yml, and build-pkg-pr-new.sh

Command tree is byte-identical (8092 lines). registry/schema/cmd unit tests,
the zero-alloc bench, and the e2e dry-run suite all pass.
2026-06-09 18:36:58 +08:00
shanglei
4215ad9908 feat(registry): build command tree from static typed meta (larkmeta)
Migrate the startup command-tree build path to read compile-time static
Go structs (metastatic.Registry, behind -tags larkmeta) instead of parsing
the 1.9MB meta_data.json into map[string]interface{} (~170K heap objects)
on every invocation.

- typed.go: typed baseline + remote-overlay layer. Reads static data under
  -tags larkmeta (zero parse / zero alloc); falls back to parsing the
  embedded meta_data.json once for builds without the tag (dev/test). A lazy
  typed->map shim (ServiceToMap/MethodToMap) keeps execution-path consumers
  working unchanged.
- service.go: registration chain and NewCmdServiceMethod read typed structs
  directly; map wrappers convert via MapToService/MapToMethod for existing
  tests; method RunE materializes opts.Spec/opts.Method lazily, never at
  startup.
- loader.go/remote.go: baseline and cached remote overlay decode into the
  typed shape; runtime refresh preserved.
- build pipeline: Makefile, goreleaser and pkg-pr-new run the generator and
  build with -tags larkmeta so shipped binaries carry the static data.

Reading the full static registry is 0 B/op, 0 allocs/op; the built command
tree is byte-identical to the JSON-parsed tree (verified via a canonical
tree dump in both modes).
2026-06-09 16:49:40 +08:00
shanglei
0b305a2248 feat(registry): typed meta schema + static-data generator (larkmeta tag)
Render the embedded command spec as static Go data (metaschema types + metastatic generator and stub) to eliminate the startup JSON parse and allocation. Behind the larkmeta build tag; the runtime still uses the JSON path (not yet wired). A validation test confirms the static data matches the JSON parse (13 services / 70 resources / 215 methods / 4233 fields) and that reads allocate nothing. Generated meta_data_gen.go is gitignored, like meta_data.json.
2026-06-09 15:37:30 +08:00
fangshuyu-768
8f5504c51c docs: improve lark-doc skill guidance (#1283) 2026-06-08 20:02:28 +08:00
fangshuyu-768
d0a896ce91 docs(skills): tighten drive and markdown guardrails (#1326) 2026-06-08 19:11:41 +08:00
fangshuyu-768
99ceb2279c feat(markdown): harden create upload failures (#1325)
* feat(markdown): harden create upload failures

* test(markdown): address AI review follow-ups
2026-06-08 18:17:35 +08:00
Emrys1105
ec2ffebf47 fix: keep bounded event consume runs alive after stdin EOF (#1285) 2026-06-08 18:09:21 +08:00
hugang-lark
ee5113f9d0 fix: optimize calendar,vc,minutes skill (#1269) 2026-06-08 17:36:05 +08:00
liangshuo-1
7cce7468d6 docs(approval): restructure skill with intent table and scope boundaries (#1307)
* docs(approval): restructure skill with intent table and scope boundaries

Rewrite the description for intent-based routing (situation framing
instead of method enumeration) and add the lark-task disambiguation.
Replace the bare method list with an intent-to-command table including
topic and add_sign_type enums, document the query-to-operate workflow
chain with a runnable example, and add an out-of-scope section routing
definition creation to the Feishu client/admin console.

Bump version to 1.1.0.

Change-Id: I33b7b13b7855d67f40954701a09b115e3c91176c

* docs(approval): strengthen description coverage of edge actions

Restore the "all processing operations" phrasing so edge actions like
remind route to this skill; weak-model routing evals regressed on the
narrower "query and process" wording (2 misses in 4 runs vs 0 after
the fix).

Change-Id: Ica1928dacf879b6c7a46dfda37e35b1be9391432

* docs(approval): drop misleading 已发起 from tasks query row

tasks query 查的是本人作为审批人的任务;已发起(本人发起的实例)应走
instances initiated,该路径已在下方表行列出。移除 tasks query 的「已发起」
标签与 topic=3 枚举,避免 agent 误用 tasks query topic=3 查已发起。
2026-06-08 17:32:10 +08:00
fangshuyu-768
281cdbd37c feat(drive): harden inspect shortcut failures (#1324) 2026-06-08 17:09:53 +08:00
ViperCai
add079ea1c docs(lark-slides): tighten routing/boundary and reconcile in-slide whiteboard (#1169)
Land the high-value, low-risk items from the skill-quality audit; SKILL.md only.

- description: drop the '接口通过 XML 协议通信' impl detail; append a 不负责
  out-of-scope clause so 'make a deck' / 'draw a diagram' stop mis-routing.
- replace the 权限速查 scope table with a ## 不在本 skill 范围 routing table
  (doc / whiteboard / drive / sheets / base).
- reconcile the whiteboard boundary with the in-slide <whiteboard> element
  (added on main, #1029): lark-whiteboard owns only standalone whiteboard
  objects in cloud docs; flow/architecture diagrams drawn inside a slide stay
  in this skill via <whiteboard>. Clarified in description and out-of-scope note.
- defer auth / permissions / global params to lark-shared as single source.
- move native-API resource hint into prose; reword schema reminder; move the
  'schema is source of truth' note next to 核心规则.

Deliberately not adopted: moving Design Ideas out of the body, relocating the
wiki-token section, dropping the native-API schema guardrail, and the bulk
lark-slides- reference rename.
2026-06-08 16:37:09 +08:00
evandance
076f4d579f feat(minutes,vc): emit typed error envelopes across both domains (#1234)
Failures from the minutes and video-conference commands now surface as
structured, typed errors carrying a stable category and subtype — spanning
input validation, missing permissions, network and file-I/O failures, and
remote API errors — so callers can branch on the error kind instead of
parsing free-form text. Batch commands report partial failures explicitly,
emitting per-item results with a non-zero exit instead of masking them.
2026-06-08 16:20:43 +08:00
SunPeiYang996
0c2fd08d5a feat:remove docs v1 api (#1291)
Change-Id: I29d0af3e5325261f94949d3ab3f65051fb6bd52b
2026-06-08 16:07:52 +08:00
liangshuo-1
9d845442ce feat: add skills command to read embedded skill content (#1318) 2026-06-08 13:58:45 +08:00
Max Huang
c07a14aa2b feat(lark-shared): document relative-path-only file arguments (#1319) 2026-06-08 13:19:03 +08:00
ethan-zhx
8b39f7243c feat: add iconpark lookup for lark slides (#1123) 2026-06-08 12:28:04 +08:00
liujinkun2025
e40ef66912 docs(lark-wiki): optimize skill guidance and routing boundaries (#1275)
- Add explicit NOT boundaries to the description and a dedicated
  "不在本 skill 范围" section: file upload -> lark-drive, content
  editing -> lark-doc / lark-sheets / lark-base.
- Move the Shortcuts table up, right after 快速决策, so command entry
  points are discoverable first; keep the member-add flow and
  target-semantics sections after it.
- Add an inline reminder under the delete-space guidance that a wiki
  URL / name is not a space_id and must be resolved via
  `wiki spaces get_node` first.
- Remove the duplicated permission (scope) table and the redundant
  schema note so auth/permission guidance stays centralized in
  lark-shared.
- Bump the skill version to 1.0.1.
- Keep skill-template/domains/wiki.md in sync with the SKILL.md
  introduction narrative.

Change-Id: If2b4341f350191ee0a65bf3a2cab9afa2b76d931
2026-06-08 11:10:59 +08:00
zhumiaoxin
e1bb9db552 feat(im): format feed group error handling (#1308) 2026-06-07 21:12:19 +08:00
zhangheng023
7c50b3d9e3 feat: fetch official skills index (#1301)
lark-cli update currently discovers official skills by parsing unstable human-oriented `skills add --list` output. This prefers the stable official JSON index for skills discovery, while preserving the existing CLI-list fallback and full-install fallback for resilience.

Changes:

- Add official skills index JSON parsing in `internal/skillscheck/sync.go`

- Prefer JSON index discovery before existing CLI list parsing in `internal/skillscheck/sync.go`

- Add reason-chain details when both discovery layers fall back to `fallbackFullInstall`

- Add bounded HTTPS fetch for `https://open.feishu.cn/.well-known/skills/index.json` in `internal/selfupdate/updater.go`

- Add unit tests for parser behavior, discovery fallback order, and fallback detail reasons in `internal/skillscheck/sync_test.go`

Co-authored-by: zhaoyukun.yk <zhaoyukun.yk@bytedance.com>
2026-06-06 18:29:04 +08:00
142 changed files with 51236 additions and 4882 deletions

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ tests/mail/reports/
.hammer/
.lark-slides/
internal/registry/meta_data.json
internal/registry/metastatic/meta_data_gen.go
cmd/api/download.bin
app.log
/sidecar-server-demo

View File

@@ -73,20 +73,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
- 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/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
- 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/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
- 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

View File

@@ -2,6 +2,8 @@ version: 2
before:
hooks:
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go),
# the sole source of the embedded command tree.
- python3 scripts/fetch_meta.py
builds:

View File

@@ -12,6 +12,9 @@ PREFIX ?= /usr/local
all: test
# fetch_meta fetches meta_data.json AND regenerates the static Go registry
# (internal/registry/metastatic/meta_data_gen.go) — the sole build-time source
# of the embedded command tree. Both are gitignored; build/vet/test depend on it.
fetch_meta:
python3 scripts/fetch_meta.py

View File

@@ -72,10 +72,12 @@ to generate QR codes (supports ASCII and PNG formats).`,
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
// Brand only — never decrypt the app secret just to build help text
// (avoids a keychain read on every `auth login --help` / completion).
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
if f != nil && f.ConfigBrand != nil {
if b, ok := f.ConfigBrand(); ok {
helpBrand = b
}
}
available := sortedKnownDomains(helpBrand)

View File

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

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Tree-dump tool: dumps the full command tree (paths, flags, descriptions,
// annotations) in a canonical, line-stable form so two builds can be diffed
// byte-for-byte (e.g. before/after a registry change). Set LARK_TREE_DUMP=<path>
// to write the dump; otherwise the test is a no-op. Not a committed golden — the
// meta data is fetched/gitignored and drifts.
package cmd_test
import (
"context"
"fmt"
"os"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func esc(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "\n", "\\n")
s = strings.ReplaceAll(s, "\t", "\\t")
s = strings.ReplaceAll(s, "\r", "\\r")
return s
}
func dumpCommandTree(root *cobra.Command) string {
var lines []string
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
path := strings.TrimSpace(strings.TrimPrefix(c.CommandPath(), "lark-cli"))
head := fmt.Sprintf("CMD %q use=%q short=%q long=%q runnable=%t hidden=%t",
path, esc(c.Use), esc(c.Short), esc(c.Long), c.Runnable(), c.Hidden)
lines = append(lines, head)
if len(c.Annotations) > 0 {
keys := make([]string, 0, len(c.Annotations))
for k := range c.Annotations {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
lines = append(lines, fmt.Sprintf(" ann %s=%q", k, esc(c.Annotations[k])))
}
}
var flags []string
c.Flags().VisitAll(func(f *pflag.Flag) {
flags = append(flags, fmt.Sprintf(" flag --%s -%s type=%s def=%q usage=%q",
f.Name, f.Shorthand, f.Value.Type(), esc(f.DefValue), esc(f.Usage)))
})
sort.Strings(flags)
lines = append(lines, flags...)
subs := c.Commands()
sort.Slice(subs, func(i, j int) bool { return subs[i].Name() < subs[j].Name() })
for _, sub := range subs {
walk(sub)
}
}
walk(root)
return strings.Join(lines, "\n") + "\n"
}
func TestDumpCommandTree(t *testing.T) {
out := os.Getenv("LARK_TREE_DUMP")
if out == "" {
t.Skip("set LARK_TREE_DUMP=<path> to dump the command tree")
}
// Deterministic: embedded meta only (no remote cache), empty config dir so
// strict-mode/plugins/policy cannot reshape the tree.
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
root := cmd.Build(context.Background(), cmdutil.InvocationContext{})
dump := dumpCommandTree(root)
if err := os.WriteFile(out, []byte(dump), 0644); err != nil {
t.Fatal(err)
}
t.Logf("wrote %d bytes, %d lines to %s", len(dump), strings.Count(dump, "\n"), out)
}

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ import (
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -30,74 +31,56 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
}
func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
for _, spec := range registry.TypedServices() {
if spec.Name == "" || spec.ServicePath == "" || len(spec.Resources) == 0 {
continue
}
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerServiceWithContext(ctx, parent, spec, resources, f)
registerServiceWithContext(ctx, parent, spec, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
registerServiceWithContext(context.Background(), parent, spec, resources, f)
svc := registry.MapToService(spec)
svc.Resources = registry.MapToResources(resources)
registerServiceWithContext(context.Background(), parent, svc, f)
}
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, f *cmdutil.Factory) {
specDesc := registry.GetServiceDescription(spec.Name, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
specDesc = spec.Description
}
// Find existing service command or create one
var svc *cobra.Command
for _, c := range parent.Commands() {
if c.Name() == specName {
if c.Name() == spec.Name {
svc = c
break
}
}
if svc == nil {
svc = &cobra.Command{
Use: specName,
Use: spec.Name,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResourceWithContext(ctx, svc, spec, resName, resMap, f)
for _, resource := range spec.Resources {
registerResourceWithContext(ctx, svc, spec, resource, f)
}
}
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, resource metaschema.Resource, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
Use: resource.Name,
Short: resource.Name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f)
for _, method := range resource.Methods {
registerMethodWithContext(ctx, res, spec, method, method.Name, resource.Name, f)
}
}
@@ -125,31 +108,36 @@ type ServiceMethodOptions struct {
FileFields []string // auto-detected file field names from metadata
}
// detectFileFields delegates to the shared cmdutil.DetectFileFields helper.
func detectFileFields(method map[string]interface{}) []string {
return cmdutil.DetectFileFields(method)
// detectFileFieldsTyped returns the names of file-type fields in the method's
// request body (used to decide whether to register --file).
func detectFileFieldsTyped(m metaschema.Method) []string {
var fields []string
for _, fld := range m.RequestBody {
if fld.Type == "file" {
fields = append(fields, fld.Name)
}
}
return fields
}
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec metaschema.Service, method metaschema.Method, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil))
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
// NewCmdServiceMethod creates a command for a dynamically registered service
// method from map specs (kept for tests; converts to typed internally).
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF)
return NewCmdServiceMethodWithContext(context.Background(), f, registry.MapToService(spec), registry.MapToMethod(name, method), name, resName, runF)
}
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
risk := registry.GetStrFromMap(method, "risk")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec metaschema.Service, method metaschema.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := method.Description
httpMethod := method.HTTPMethod
risk := method.Risk
schemaPath := fmt.Sprintf("%s.%s.%s", spec.Name, resName, name)
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
}
var asStr string
@@ -159,6 +147,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
RunE: func(cmd *cobra.Command, args []string) error {
// Materialize the maps the execution path still reads lazily — only
// when THIS command actually runs, never at startup.
opts.Spec = registry.ServiceToMap(spec)
opts.Method = registry.MethodToMap(method)
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
@@ -188,7 +180,7 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
}
// Conditionally register --file for methods with file-type fields.
fileFields := detectFileFields(method)
fileFields := detectFileFieldsTyped(method)
opts.FileFields = fileFields
if len(fileFields) > 0 {
switch httpMethod {
@@ -200,10 +192,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
// meta_data.json carries no per-method tips; SetTips(nil) matches prior behavior.
cmdutil.SetTips(cmd, nil)
cmdutil.SetRisk(cmd, risk)
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens))
if len(method.AccessTokens) > 0 {
toks := make([]interface{}, len(method.AccessTokens))
for i, t := range method.AccessTokens {
toks[i] = t
}
cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(toks))
}
return cmd

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/registry"
"github.com/spf13/cobra"
)
@@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectFileFields(tt.method)
got := detectFileFieldsTyped(registry.MapToMethod("", tt.method))
if len(got) != len(tt.want) {
t.Errorf("detectFileFields() = %v, want %v", got, tt.want)
return

183
cmd/skill/skill.go Normal file
View File

@@ -0,0 +1,183 @@
// 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)
}

306
cmd/skill/skill_test.go Normal file
View File

@@ -0,0 +1,306 @@
// 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

@@ -49,12 +49,21 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.NpmInstallOverride = npmFn
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(`{"skills":[{"name":"lark-calendar"},{"name":"lark-mail"}]}`)
return r
}
}
func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
return func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
@@ -478,6 +487,10 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return errors.New("bad binary") }
u.RestoreAvailableOverride = func() bool { return false }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
t.Fatal("skills sync should not run when binary verification fails")
return nil
@@ -810,6 +823,11 @@ func TestUpdateNpm_SkillsFail_JSON(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -862,6 +880,11 @@ func TestUpdateNpm_SkillsFail_Human(t *testing.T) {
}
u.NpmInstallOverride = func(version string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} }
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Err = fmt.Errorf("index unavailable")
return r
}
u.SkillsCommandOverride = func(args ...string) *selfupdate.NpmResult {
r := &selfupdate.NpmResult{}
r.Stderr.WriteString("npx: command not found")
@@ -1006,6 +1029,7 @@ func TestUpdateRun_AlreadyLatest_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1044,6 +1068,7 @@ func TestUpdateRun_Manual_RunsSkillsSync(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallManual,
@@ -1088,6 +1113,7 @@ func TestUpdateRun_Npm_RunsSkillsSync_WritesLatestState(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{
Method: selfupdate.InstallNpm, NpmAvailable: true,
@@ -1147,6 +1173,10 @@ func TestUpdateRun_CheckIncludesSkillsStatus(t *testing.T) {
DetectOverride: func() selfupdate.DetectResult {
return selfupdate.DetectResult{Method: selfupdate.InstallNpm, NpmAvailable: true}
},
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)
@@ -1196,6 +1226,10 @@ func TestUpdateRun_CheckAlreadyLatest_NoSideEffect(t *testing.T) {
t.Cleanup(func() { newUpdater = origNew })
newUpdater = func() *selfupdate.Updater {
return &selfupdate.Updater{
SkillsIndexFetchOverride: func() *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsIndexFetch()()
},
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
skillsCalled = true
return successfulSkillsCommand()(args...)

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -29,10 +30,11 @@ type InvocationContext struct {
}
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Config func() (*core.CliConfig, error) // lazily loads app config from Credential
ConfigBrand func() (core.LarkBrand, bool) // brand only, no secret decryption — for startup help/registration (avoids keychain)
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Invocation InvocationContext // Immutable call context; do not mutate after Factory construction.
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
@@ -43,6 +45,8 @@ type Factory struct {
Credential *credential.CredentialProvider
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
@@ -148,11 +152,14 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
if f.Credential == nil {
return core.StrictModeOff
}
acct, err := f.Credential.ResolveAccount(ctx)
if err != nil || acct == nil {
// Strict mode is plain config metadata; resolve it WITHOUT decrypting the
// app secret so identity-flag registration at startup never touches the
// keychain (ResolveStrictMode is called per command during Build).
_, supported, ok := f.Credential.ResolveMeta(ctx)
if !ok {
return core.StrictModeOff
}
ids := extcred.IdentitySupport(acct.SupportedIdentities)
ids := extcred.IdentitySupport(supported)
switch {
case ids.BotOnly():
return core.StrictModeBot

View File

@@ -78,6 +78,18 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// ConfigBrand resolves just the brand without decrypting the app secret, so
// brand-aware help and shortcut registration at startup do not touch the
// keychain. It still initializes the registry with the resolved brand — the
// same side effect Config has, minus the secret.
f.ConfigBrand = sync.OnceValues(func() (core.LarkBrand, bool) {
brand, _, ok := f.Credential.ResolveMeta(context.Background())
if ok {
registry.InitWithBrand(brand)
}
return brand, ok
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)

View File

@@ -65,7 +65,13 @@ func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer,
)
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
Config: func() (*core.CliConfig, error) { return config, nil },
ConfigBrand: func() (core.LarkBrand, bool) {
if config != nil {
return config.Brand, true
}
return "", false
},
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},

View File

@@ -21,6 +21,14 @@ type DefaultAccountResolver interface {
ResolveAccount(ctx context.Context) (*Account, error)
}
// metaResolver is an optional capability: resolve config metadata (brand +
// strict-mode identity support) without resolving the app secret (no keychain
// access). Providers that don't implement it fall back to ResolveAccount inside
// CredentialProvider.ResolveMeta.
type metaResolver interface {
ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool)
}
// DefaultTokenResolver is implemented by the default token provider.
type DefaultTokenResolver interface {
ResolveToken(ctx context.Context, req TokenSpec) (*TokenResult, error)
@@ -141,6 +149,11 @@ type CredentialProvider struct {
accountErr error
selectedSource credentialSource
metaOnce sync.Once
metaBrand core.LarkBrand
metaIdents uint8
metaOK bool
hintOnce sync.Once
hint *IdentityHint
hintErr error
@@ -172,6 +185,44 @@ func (p *CredentialProvider) ResolveAccount(ctx context.Context) (*Account, erro
return p.account, p.accountErr
}
// ResolveMeta resolves config metadata — brand and strict-mode identity support
// — cheaply, WITHOUT decrypting the app secret for the default
// (config.json/keychain) provider. It mirrors doResolveAccount's provider
// selection: external providers (env/sidecar) are asked first via ResolveAccount
// (they do not touch the keychain), then the default provider's keychain-free
// metaResolver path. Cached after first call. Best-effort: returns ok=false when
// nothing is configured, so callers keep their defaults. Used for brand-aware
// help text, shortcut registration, and strict-mode checks at startup, where
// decrypting the secret would be wasteful.
func (p *CredentialProvider) ResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
p.metaOnce.Do(func() {
p.metaBrand, p.metaIdents, p.metaOK = p.doResolveMeta(ctx)
})
return p.metaBrand, p.metaIdents, p.metaOK
}
func (p *CredentialProvider) doResolveMeta(ctx context.Context) (core.LarkBrand, uint8, bool) {
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)
if err != nil {
return "", 0, false
}
if acct != nil {
internal := convertAccount(acct)
return internal.Brand, internal.SupportedIdentities, true
}
}
if p.defaultAcct != nil {
if mr, ok := p.defaultAcct.(metaResolver); ok {
return mr.ResolveMeta(ctx)
}
if acct, err := p.defaultAcct.ResolveAccount(ctx); err == nil && acct != nil {
return acct.Brand, acct.SupportedIdentities, true
}
}
return "", 0, false
}
func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, error) {
for _, prov := range p.providers {
acct, err := prov.ResolveAccount(ctx)

View File

@@ -76,6 +76,23 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account,
return AccountFromCliConfig(cfg), nil
}
// ResolveMeta returns config metadata — brand and the strict-mode identity
// support — from config.json WITHOUT resolving the app secret (no keychain
// access). Both are plain config fields, so brand-aware help, shortcut
// registration, and strict-mode checks at startup need not decrypt the secret.
// Returns ok=false when no config exists, so callers keep their defaults.
func (p *DefaultAccountProvider) ResolveMeta(_ context.Context) (core.LarkBrand, uint8, bool) {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return "", 0, false
}
app := multi.CurrentAppConfig(p.profile)
if app == nil {
return "", 0, false
}
return app.Brand, strictModeToIdentitySupport(multi, p.profile), true
}
// strictModeToIdentitySupport maps the config-level strict mode to
// the SupportedIdentities bitflag using an already-loaded MultiAppConfig.
func strictModeToIdentitySupport(multi *core.MultiAppConfig, profileOverride string) uint8 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,72 +19,32 @@ import (
//go:embed scope_priorities.json scope_overrides.json
var registryFS embed.FS
// embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in.
var embeddedMetaJSON []byte
// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers
// that need to parse key order or other JSON-level structure not exposed by
// LoadFromMeta (which loses map insertion order).
func EmbeddedMetaJSON() []byte {
return embeddedMetaJSON
}
var (
embeddedServicesMap map[string]map[string]interface{} // service name -> spec
embeddedServiceNames []string // sorted
embeddedParseOnce sync.Once
)
// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map
// without touching mergedServices. Safe to call multiple times (sync.Once).
func parseEmbeddedServices() {
embeddedParseOnce.Do(func() {
embeddedServicesMap = make(map[string]map[string]interface{})
if len(embeddedMetaJSON) == 0 {
return
}
var wrapper struct {
Services []map[string]interface{} `json:"services"`
}
if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil {
return
}
for _, svc := range wrapper.Services {
name, _ := svc["name"].(string)
if name == "" {
continue
}
embeddedServicesMap[name] = svc
}
embeddedServiceNames = make([]string, 0, len(embeddedServicesMap))
for name := range embeddedServicesMap {
embeddedServiceNames = append(embeddedServiceNames, name)
}
sort.Strings(embeddedServiceNames)
})
}
// EmbeddedSpec returns the embedded spec for one service, or nil if unknown.
// Bypasses remote overlay — used for deterministic envelope output.
// EmbeddedSpec returns the embedded baseline spec for one service as a map, or
// nil if the service is unknown. It reads the static compile-time registry
// (metastatic.Registry) and bypasses the remote overlay, so envelope output is
// deterministic across machines.
func EmbeddedSpec(serviceName string) map[string]interface{} {
parseEmbeddedServices()
return embeddedServicesMap[serviceName]
if svc, ok := baselineServiceByName(serviceName); ok {
return ServiceToMap(svc)
}
return nil
}
// EmbeddedServiceNames returns sorted embedded service names (no overlay).
// Returns a defensive copy — callers must not mutate the package-level slice.
// EmbeddedServiceNames returns the embedded baseline service names, sorted
// (no remote overlay).
func EmbeddedServiceNames() []string {
parseEmbeddedServices()
out := make([]string, len(embeddedServiceNames))
copy(out, embeddedServiceNames)
svcs := baselineServices()
out := make([]string, 0, len(svcs))
for _, s := range svcs {
out = append(out, s.Name)
}
sort.Strings(out)
return out
}
var (
mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec
mergedProjectList []string // sorted project names
embeddedVersion string // version from embedded meta_data.json
initOnce sync.Once
embeddedVersion string // baseline data version (from the static registry)
initOnce sync.Once
)
// Init initializes the registry with default brand (feishu).
@@ -101,55 +61,27 @@ func Init() {
func InitWithBrand(brand core.LarkBrand) {
initOnce.Do(func() {
configuredBrand = brand
// 1. Load embedded meta_data.json as baseline (no-op if not compiled in)
loadEmbeddedIntoMerged()
// 2. Remote overlay
// 1. Baseline version: the static compile-time registry (metastatic).
embeddedVersion = baselineVersion()
// 2. Remote overlay — still fetched/refreshed at runtime, decoded into
// the same typed shape and merged over the baseline.
if remoteEnabled() && cacheWritable() {
// Check if brand changed since last cache
meta, metaErr := loadCacheMeta()
brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand)
if !brandChanged {
if cached, err := loadCachedMerged(); err == nil {
overlayMergedServices(cached)
}
_ = loadCachedTyped()
}
if len(mergedServices) == 0 || brandChanged {
// No data at all or brand changed — must sync fetch
if !hasTypedData() || brandChanged {
// No data at all (e.g. stub build, no cache) or brand changed.
doSyncFetch()
} else if shouldRefresh(meta) || metaErr != nil {
// Have embedded/cached data; refresh in background if TTL expired or first run
triggerBackgroundRefresh()
}
}
// 3. Build sorted project list
rebuildProjectList()
})
}
// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates
// mergedServices. No-op if meta_data.json is not compiled in.
func loadEmbeddedIntoMerged() {
if len(embeddedMetaJSON) == 0 {
return
}
var reg MergedRegistry
if err := json.Unmarshal(embeddedMetaJSON, &reg); err != nil {
return
}
embeddedVersion = reg.Version
overlayMergedServices(&reg)
}
// rebuildProjectList rebuilds the sorted list of project names from mergedServices.
func rebuildProjectList() {
mergedProjectList = make([]string, 0, len(mergedServices))
for name := range mergedServices {
mergedProjectList = append(mergedProjectList, name)
}
sort.Strings(mergedProjectList)
}
var cachedAllScopes map[string][]string
// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json
@@ -226,7 +158,11 @@ func CollectAllScopesFromMeta(identity string) []string {
// It returns data from the merged registry (embedded + cached remote overlay).
func LoadFromMeta(project string) map[string]interface{} {
Init()
return mergedServices[project]
svc, ok := typedServiceByName(project)
if !ok {
return nil
}
return ServiceToMap(svc)
}
// ListFromMetaProjects lists available service project names (sorted).
@@ -234,7 +170,7 @@ func LoadFromMeta(project string) map[string]interface{} {
//go:noinline
func ListFromMetaProjects() []string {
Init()
return mergedProjectList
return typedServiceNames()
}
// DefaultScopeScore is the score assigned to scopes not in the priorities table.

View File

@@ -1,20 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import "embed"
//go:embed meta_data*.json
var metaFS embed.FS
//go:embed meta_data_default.json
var embeddedMetaDataDefaultJSON []byte
func init() {
if data, err := metaFS.ReadFile("meta_data.json"); err == nil && len(data) > 0 {
embeddedMetaJSON = data
} else {
embeddedMetaJSON = embeddedMetaDataDefaultJSON
}
}

View File

@@ -1 +0,0 @@
{"version":"0.0.0","services":[]}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package metaschema defines the typed shape of the command-spec registry
// (meta_data.json). The embedded baseline is emitted as static Go data in
// package metastatic (no runtime JSON parse, no startup allocation); the remote
// overlay is decoded into these same types at runtime.
//
// All container fields are slices (never maps): a package-level slice literal is
// laid out in the binary's data section and costs zero heap allocation at
// startup, whereas a map literal builds an hmap at init time. Map keys from the
// JSON (resource/method/field names) are preserved in the Name field.
package metaschema
// Registry is the top level of meta_data.json: {version, services:[...]}.
type Registry struct {
Version string
Services []Service
}
// Service is one API domain (e.g. "im", "calendar").
type Service struct {
Name string
Version string
Title string
Description string
ServicePath string
Resources []Resource // JSON "resources" map, keyed by Resource.Name
}
// Resource groups methods under a service (e.g. "messages").
type Resource struct {
Name string
Methods []Method // JSON "methods" map, keyed by Method.Name
}
// Method is a single API call.
type Method struct {
Name string // JSON map key
ID string
Path string
HTTPMethod string
Description string
Risk string
DocURL string
Danger bool
Scopes []string
AccessTokens []string
ParameterOrder []string
RequiredScopes []string
Parameters []Field // JSON "parameters" map, keyed by Field.Name
RequestBody []Field // JSON "requestBody" map
ResponseBody []Field // JSON "responseBody" map
Affordance *Affordance // optional AI-facing usage overlay; nil on most methods
}
// Field is one parameter / request-body / response-body entry. Nested object
// fields recurse via Properties.
type Field struct {
Name string // JSON map key
Type string
Location string
Description string
Default string
Example string
EnumName string
Min string
Max string
Ref string
Required bool
Options []Option
Enum []string
Annotations []string
Properties []Field
}
// Option is one allowed value for a field with an enum-like option list.
type Option struct {
Value string
Description string
}
// Affordance is the optional AI-facing usage overlay for a method, surfaced in
// the schema envelope as _meta.affordance. Absent (nil) on most methods; it is
// authored upstream in registry-config.yaml and merged into meta_data.json.
type Affordance struct {
UseWhen []string
DoNotUseWhen []string
Prerequisites []string
Examples []AffordanceExample
Related []string
}
// AffordanceExample is one ready-to-run example: a one-line description plus a
// complete lark-cli command string.
type AffordanceExample struct {
Description string
Command string
}

View File

@@ -0,0 +1,255 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build ignore
// Command gen reads internal/registry/meta_data.json and emits
// meta_data_gen.go: the embedded command spec as a single static
// metaschema.Registry literal (zero runtime JSON parse, zero startup heap
// allocation). Run via: go run internal/registry/metastatic/gen.go
//
// Maps in the JSON (resources/methods/fields) are emitted as slices sorted by
// key so generation is deterministic.
package main
import (
"encoding/json"
"fmt"
"go/format"
"os"
"sort"
"strings"
)
const (
inPath = "internal/registry/meta_data.json"
outPath = "internal/registry/metastatic/meta_data_gen.go"
)
func gs(m map[string]any, k string) string {
if v, ok := m[k].(string); ok {
return v
}
return ""
}
func gb(m map[string]any, k string) bool {
if v, ok := m[k].(bool); ok {
return v
}
return false
}
func gss(m map[string]any, k string) []string {
raw, _ := m[k].([]any)
out := make([]string, 0, len(raw))
for _, e := range raw {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func gm(m map[string]any, k string) map[string]any {
if v, ok := m[k].(map[string]any); ok {
return v
}
return nil
}
func sortedKeys(m map[string]any) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func emitStrSlice(b *strings.Builder, name string, vs []string) {
if len(vs) == 0 {
return
}
fmt.Fprintf(b, "%s: []string{", name)
for _, v := range vs {
fmt.Fprintf(b, "%q, ", v)
}
b.WriteString("},\n")
}
func emitOptions(b *strings.Builder, raw []any) {
if len(raw) == 0 {
return
}
b.WriteString("Options: []metaschema.Option{")
for _, e := range raw {
o, _ := e.(map[string]any)
fmt.Fprintf(b, "{Value: %q, Description: %q}, ", gs(o, "value"), gs(o, "description"))
}
b.WriteString("},\n")
}
// emitFields emits a metaschema.Field slice from a JSON map[fieldName]fieldSpec.
func emitFields(b *strings.Builder, label string, fm map[string]any) {
if len(fm) == 0 {
return
}
fmt.Fprintf(b, "%s: []metaschema.Field{\n", label)
for _, name := range sortedKeys(fm) {
f, _ := fm[name].(map[string]any)
if f == nil {
continue
}
b.WriteString("{")
fmt.Fprintf(b, "Name: %q, ", name)
for _, kv := range []struct{ k, field string }{
{"type", "Type"}, {"location", "Location"}, {"description", "Description"},
{"default", "Default"}, {"example", "Example"}, {"enumName", "EnumName"},
{"min", "Min"}, {"max", "Max"}, {"ref", "Ref"},
} {
if v := gs(f, kv.k); v != "" {
fmt.Fprintf(b, "%s: %q, ", kv.field, v)
}
}
if gb(f, "required") {
b.WriteString("Required: true, ")
}
emitStrSlice(b, "Enum", gss(f, "enum"))
emitStrSlice(b, "Annotations", gss(f, "annotations"))
if opts, ok := f["options"].([]any); ok {
emitOptions(b, opts)
}
if props := gm(f, "properties"); props != nil {
emitFields(b, "Properties", props)
}
b.WriteString("},\n")
}
b.WriteString("},\n")
}
// emitAffordance emits a metaschema.Affordance literal from a method's
// "affordance" JSON object, or nothing when absent/empty.
func emitAffordance(b *strings.Builder, raw map[string]any) {
if raw == nil {
return
}
useWhen := gss(raw, "use_when")
doNot := gss(raw, "do_not_use_when")
prereq := gss(raw, "prerequisites")
related := gss(raw, "related")
examples, _ := raw["examples"].([]any)
if len(useWhen) == 0 && len(doNot) == 0 && len(prereq) == 0 && len(related) == 0 && len(examples) == 0 {
return
}
b.WriteString("Affordance: &metaschema.Affordance{")
emitStrSlice(b, "UseWhen", useWhen)
emitStrSlice(b, "DoNotUseWhen", doNot)
emitStrSlice(b, "Prerequisites", prereq)
if len(examples) > 0 {
b.WriteString("Examples: []metaschema.AffordanceExample{")
for _, e := range examples {
ex, _ := e.(map[string]any)
fmt.Fprintf(b, "{Description: %q, Command: %q}, ", gs(ex, "description"), gs(ex, "command"))
}
b.WriteString("},\n")
}
emitStrSlice(b, "Related", related)
b.WriteString("},\n")
}
func emitMethods(b *strings.Builder, mm map[string]any) {
b.WriteString("Methods: []metaschema.Method{\n")
for _, name := range sortedKeys(mm) {
m, _ := mm[name].(map[string]any)
if m == nil {
continue
}
b.WriteString("{")
fmt.Fprintf(b, "Name: %q, ID: %q, Path: %q, HTTPMethod: %q, Description: %q, ",
name, gs(m, "id"), gs(m, "path"), gs(m, "httpMethod"), gs(m, "description"))
if v := gs(m, "risk"); v != "" {
fmt.Fprintf(b, "Risk: %q, ", v)
}
if v := gs(m, "docUrl"); v != "" {
fmt.Fprintf(b, "DocURL: %q, ", v)
}
if gb(m, "danger") {
b.WriteString("Danger: true, ")
}
b.WriteString("\n")
emitStrSlice(b, "Scopes", gss(m, "scopes"))
emitStrSlice(b, "AccessTokens", gss(m, "accessTokens"))
emitStrSlice(b, "ParameterOrder", gss(m, "parameterOrder"))
emitStrSlice(b, "RequiredScopes", gss(m, "requiredScopes"))
emitFields(b, "Parameters", gm(m, "parameters"))
emitFields(b, "RequestBody", gm(m, "requestBody"))
emitFields(b, "ResponseBody", gm(m, "responseBody"))
emitAffordance(b, gm(m, "affordance"))
b.WriteString("},\n")
}
b.WriteString("},\n")
}
func main() {
data, err := os.ReadFile(inPath)
if err != nil {
fmt.Fprintln(os.Stderr, "read:", err)
os.Exit(1)
}
var reg map[string]any
if err := json.Unmarshal(data, &reg); err != nil {
fmt.Fprintln(os.Stderr, "unmarshal:", err)
os.Exit(1)
}
var b strings.Builder
b.WriteString("// Code generated from meta_data.json by gen.go. DO NOT EDIT.\n")
b.WriteString("// Gitignored; produced at build time by `make fetch_meta`.\n\n")
b.WriteString("package metastatic\n\n")
b.WriteString("import \"github.com/larksuite/cli/internal/registry/metaschema\"\n\n")
b.WriteString("// registryData holds the command spec as static Go data. It is a\n")
b.WriteString("// package-level var, so its backing arrays live in the binary's static\n")
b.WriteString("// section (zero heap alloc on read). init() wires it into the Registry\n")
b.WriteString("// declared by stub.go with a single struct-header copy. No build tag is\n")
b.WriteString("// needed: when this generated file is absent (fresh checkout) stub.go's\n")
b.WriteString("// empty Registry stands alone; when present, init() augments it.\n")
b.WriteString("var registryData = metaschema.Registry{\n")
fmt.Fprintf(&b, "Version: %q,\n", gs(reg, "version"))
b.WriteString("Services: []metaschema.Service{\n")
svcs, _ := reg["services"].([]any)
for _, sv := range svcs {
s, _ := sv.(map[string]any)
if s == nil {
continue
}
b.WriteString("{")
fmt.Fprintf(&b, "Name: %q, Version: %q, Title: %q, Description: %q, ServicePath: %q,\n",
gs(s, "name"), gs(s, "version"), gs(s, "title"), gs(s, "description"), gs(s, "servicePath"))
b.WriteString("Resources: []metaschema.Resource{\n")
res := gm(s, "resources")
for _, rname := range sortedKeys(res) {
r, _ := res[rname].(map[string]any)
if r == nil {
continue
}
fmt.Fprintf(&b, "{Name: %q,\n", rname)
emitMethods(&b, gm(r, "methods"))
b.WriteString("},\n")
}
b.WriteString("},\n") // Resources
b.WriteString("},\n") // Service
}
b.WriteString("},\n") // Services
b.WriteString("}\n\n") // registryData literal
b.WriteString("func init() { Registry = registryData }\n")
src, err := format.Source([]byte(b.String()))
if err != nil {
// Write unformatted for debugging, then fail.
_ = os.WriteFile(outPath+".broken", []byte(b.String()), 0644)
fmt.Fprintln(os.Stderr, "gofmt:", err)
os.Exit(1)
}
if err := os.WriteFile(outPath, src, 0644); err != nil {
fmt.Fprintln(os.Stderr, "write:", err)
os.Exit(1)
}
fmt.Printf("wrote %s (%d services, %d bytes)\n", outPath, len(svcs), len(src))
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package metastatic
import "github.com/larksuite/cli/internal/registry/metaschema"
// Registry is the command spec as static Go data. It is declared here (zero
// value) so the package always compiles, and populated by meta_data_gen.go's
// init() when that generated file is present. On a fresh checkout the generated
// file is absent — it is gitignored and produced at build time by
// `make gen_meta` — so Registry stays empty. This keeps the "heavy spec is
// never committed, only generated" model, now without a build tag: the
// generated file augments this one rather than replacing it under a tag.
var Registry = metaschema.Registry{}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Validation for the static-meta registry: the generated metastatic.Registry is
// the sole embedded baseline (no JSON parsed at runtime), and a deep read of it
// allocates nothing. The data is generated from meta_data.json at build time
// (`make fetch_meta`) and is gitignored, so these tests skip on a bare checkout
// where it has not been generated yet.
package registry
import (
"testing"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/registry/metastatic"
)
func countFieldsStatic(fs []metaschema.Field) int {
n := 0
for _, f := range fs {
n++
n += countFieldsStatic(f.Properties)
}
return n
}
func countStatic() (svc, res, meth, fld int) {
svc = len(metastatic.Registry.Services)
for _, s := range metastatic.Registry.Services {
for _, r := range s.Resources {
res++
for _, m := range r.Methods {
meth++
fld += countFieldsStatic(m.Parameters) + countFieldsStatic(m.RequestBody) + countFieldsStatic(m.ResponseBody)
}
}
}
return
}
// TestStaticRegistryPopulated checks the generated registry carries data. It
// skips on a bare checkout where meta_data_gen.go has not been generated yet.
func TestStaticRegistryPopulated(t *testing.T) {
if len(metastatic.Registry.Services) == 0 {
t.Skip("static registry empty; run `make fetch_meta` to generate it")
}
svc, res, meth, fld := countStatic()
t.Logf("static: services=%d resources=%d methods=%d fields=%d", svc, res, meth, fld)
if svc == 0 || res == 0 || meth == 0 || fld == 0 {
t.Fatalf("static registry incomplete: svc=%d res=%d meth=%d fld=%d", svc, res, meth, fld)
}
if metastatic.Registry.Version == "" {
t.Error("static registry has empty Version")
}
}
var sinkInt int
// --- zero-alloc: a deep read of the static registry must allocate nothing ---
func deepReadStatic() int {
n := 0
for _, s := range metastatic.Registry.Services {
n += len(s.Name)
for _, r := range s.Resources {
for _, m := range r.Methods {
n += len(m.ID) + len(m.Scopes) + countFieldsStatic(m.Parameters) + countFieldsStatic(m.ResponseBody)
}
}
}
return n
}
func TestStaticReadZeroAlloc(t *testing.T) {
if len(metastatic.Registry.Services) == 0 {
t.Skip("static registry empty; run `make fetch_meta` to generate it")
}
avg := testing.AllocsPerRun(50, func() { sinkInt = deepReadStatic() })
t.Logf("static deep-read: %.1f allocs/op", avg)
if avg > 0 {
t.Errorf("static read allocates %.1f/op, want 0 (data should be in the binary, not heap)", avg)
}
}
func BenchmarkReadStaticRegistry(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkInt = deepReadStatic()
}
}

View File

@@ -147,22 +147,6 @@ func saveCacheMeta(meta CacheMeta) error {
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
}
func loadCachedMerged() (*MergedRegistry, error) {
path := cachePath()
data, err := vfs.ReadFile(path)
if err != nil {
return nil, err
}
var reg MergedRegistry
if err := json.Unmarshal(data, &reg); err != nil {
// Cache corrupted — remove it so next run triggers a fresh fetch
vfs.Remove(path)
vfs.Remove(cacheMetaPath())
return nil, err
}
return &reg, nil
}
func saveCachedMerged(data []byte, meta CacheMeta) error {
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
return err
@@ -253,7 +237,7 @@ func doSyncFetch() {
Brand: string(configuredBrand),
}
_ = saveCachedMerged(data, meta)
overlayMergedServices(reg)
_ = loadCachedTyped()
}
// --- background refresh ---
@@ -308,15 +292,3 @@ func shouldRefresh(meta CacheMeta) bool {
}
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
}
// overlayMergedServices merges remote services into the in-memory map.
// Remote entries override embedded entries with the same name.
func overlayMergedServices(reg *MergedRegistry) {
for _, svc := range reg.Services {
name, ok := svc["name"].(string)
if !ok || name == "" {
continue
}
mergedServices[name] = svc
}
}

View File

@@ -15,6 +15,8 @@ import (
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/registry/metastatic"
)
// waitBackgroundRefresh blocks until any in-flight background refresh started by
@@ -30,8 +32,7 @@ func resetInit() {
// reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant).
waitBackgroundRefresh()
initOnce = sync.Once{}
mergedServices = make(map[string]map[string]interface{})
mergedProjectList = nil
resetTyped()
embeddedVersion = ""
cachedAllScopes = nil
cachedScopePriorities = nil
@@ -55,16 +56,10 @@ func TestResetInitClearsEmbeddedVersion(t *testing.T) {
}
}
// hasEmbeddedServices returns true if meta_data.json with real services is compiled in.
// hasEmbeddedServices returns true if the static registry has services compiled
// in (generated from meta_data.json at build time).
func hasEmbeddedServices() bool {
if len(embeddedMetaJSON) == 0 {
return false
}
var reg MergedRegistry
if err := json.Unmarshal(embeddedMetaJSON, &reg); err != nil {
return false
}
return len(reg.Services) > 0
return len(metastatic.Registry.Services) > 0
}
// testRegistry returns a minimal MergedRegistry with one service.
@@ -302,50 +297,36 @@ func TestMetaTTL(t *testing.T) {
}
}
func TestOverlayMergedServices(t *testing.T) {
func TestRemoteOverlayTyped(t *testing.T) {
resetInit()
mergedServices = make(map[string]map[string]interface{})
mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"}
setRemoteOverrides([]metaschema.Service{
{Name: "existing", Version: "v2"},
{Name: "brand_new", Version: "v1"},
})
reg := &MergedRegistry{
Services: []map[string]interface{}{
{"name": "existing", "version": "v2"},
{"name": "brand_new", "version": "v1"},
},
// override present
if s, ok := typedServiceByName("existing"); !ok || s.Version != "v2" {
t.Errorf("expected existing override v2, got %+v ok=%v", s, ok)
}
overlayMergedServices(reg)
// existing should be overridden
if v := mergedServices["existing"]["version"].(string); v != "v2" {
t.Errorf("expected existing to be overridden to v2, got %s", v)
}
// brand_new should be added
if _, ok := mergedServices["brand_new"]; !ok {
// new service added
if _, ok := typedServiceByName("brand_new"); !ok {
t.Error("expected brand_new to be added")
}
}
func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) {
func TestRemoteOverlayDoesNotPolluteFollowingInit(t *testing.T) {
resetInit()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
const leakedExisting = "test_isolation_existing_sentinel"
const leakedOverlay = "test_isolation_overlay_sentinel"
mergedServices = map[string]map[string]interface{}{
leakedExisting: {"name": leakedExisting, "version": "v1"},
}
overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}})
const leaked = "test_isolation_overlay_sentinel"
setRemoteOverrides([]metaschema.Service{{Name: leaked, Version: "v1"}})
resetInit()
Init()
if spec := LoadFromMeta(leakedExisting); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leakedExisting)
}
if spec := LoadFromMeta(leakedOverlay); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leakedOverlay)
if spec := LoadFromMeta(leaked); spec != nil {
t.Fatalf("polluted service %q survived resetInit", leaked)
}
}
@@ -425,8 +406,8 @@ func TestCorruptedCache_SelfHeals(t *testing.T) {
metaData, _ := json.Marshal(meta)
os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), metaData, 0644)
// loadCachedMerged should fail and remove the corrupted files
_, err := loadCachedMerged()
// loadCachedTyped should fail and remove the corrupted files
err := loadCachedTyped()
if err == nil {
t.Fatal("expected error for corrupted cache")
}

579
internal/registry/typed.go Normal file
View File

@@ -0,0 +1,579 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"sort"
"sync"
"github.com/larksuite/cli/internal/registry/metaschema"
"github.com/larksuite/cli/internal/registry/metastatic"
"github.com/larksuite/cli/internal/vfs"
)
// This file is the typed registry layer for the static-meta migration.
//
// - The embedded baseline is metastatic.Registry: static Go data laid out in
// the binary at compile time (zero startup cost). It is empty on a fresh
// checkout (stub.go) until the generated meta_data_gen.go is produced by
// `make fetch_meta`; no build tag is involved.
// - The remote overlay (~/.lark-cli/cache/remote_meta.json) is still fetched
// and refreshed at runtime, decoded into the same typed shape, and merged
// over the baseline as per-service overrides.
//
// Startup (command-tree build) reads these typed structs directly. Execution-
// path consumers that still expect map[string]interface{} go through
// ServiceToMap, which rebuilds one service's map lazily, on demand — never the
// whole spec at startup.
var (
typedMu sync.RWMutex
remoteOverrides map[string]metaschema.Service // service name -> remote override
typedNamesCache []string
)
// resetTyped clears the typed overlay state (test/teardown helper).
func resetTyped() {
typedMu.Lock()
defer typedMu.Unlock()
remoteOverrides = nil
typedNamesCache = nil
}
// baselineServices returns the embedded baseline service specs: the static
// compile-time data in metastatic.Registry (zero parse, zero alloc). It is
// empty only on a fresh checkout where meta_data_gen.go has not been generated
// yet (see stub.go).
var (
baselineOnce sync.Once
baselineSvcs []metaschema.Service
baselineVer string
)
func loadBaseline() {
baselineOnce.Do(func() {
baselineSvcs = metastatic.Registry.Services
baselineVer = metastatic.Registry.Version
})
}
func baselineServices() []metaschema.Service {
loadBaseline()
return baselineSvcs
}
func baselineVersion() string {
loadBaseline()
return baselineVer
}
// baselineServiceByName returns the embedded baseline service spec by name.
func baselineServiceByName(name string) (metaschema.Service, bool) {
svcs := baselineServices()
for i := range svcs {
if svcs[i].Name == name {
return svcs[i], true
}
}
return metaschema.Service{}, false
}
// typedServiceByName returns the effective typed spec for a service: the remote
// override if present, otherwise the static baseline.
func typedServiceByName(name string) (metaschema.Service, bool) {
typedMu.RLock()
if s, ok := remoteOverrides[name]; ok {
typedMu.RUnlock()
return s, true
}
typedMu.RUnlock()
return baselineServiceByName(name)
}
// typedServiceNames returns all effective service names (baseline + remote
// additions), sorted. Cached until the overlay changes.
func typedServiceNames() []string {
typedMu.RLock()
if typedNamesCache != nil {
out := typedNamesCache
typedMu.RUnlock()
return out
}
typedMu.RUnlock()
seen := make(map[string]bool)
for _, s := range baselineServices() {
seen[s.Name] = true
}
typedMu.RLock()
for name := range remoteOverrides {
seen[name] = true
}
typedMu.RUnlock()
names := make([]string, 0, len(seen))
for n := range seen {
names = append(names, n)
}
sort.Strings(names)
typedMu.Lock()
typedNamesCache = names
typedMu.Unlock()
return names
}
// setRemoteOverrides installs the parsed remote overlay (called from Init).
func setRemoteOverrides(svcs []metaschema.Service) {
typedMu.Lock()
defer typedMu.Unlock()
if remoteOverrides == nil {
remoteOverrides = make(map[string]metaschema.Service, len(svcs))
}
for _, s := range svcs {
remoteOverrides[s.Name] = s
}
typedNamesCache = nil
}
// TypedService returns the effective typed spec for a service (remote override
// or static baseline). Public accessor for the command-tree builder.
func TypedService(name string) (metaschema.Service, bool) {
Init()
return typedServiceByName(name)
}
// TypedServices returns all effective service specs, sorted by name. Reading
// these builds nothing on the heap (static data); the remote overlay, if any,
// was allocated once at Init.
func TypedServices() []metaschema.Service {
Init()
names := typedServiceNames()
out := make([]metaschema.Service, 0, len(names))
for _, n := range names {
if s, ok := typedServiceByName(n); ok {
out = append(out, s)
}
}
return out
}
// hasTypedData reports whether any typed spec is available (static baseline or
// remote overlay). False only when the static registry has not been generated
// (fresh checkout) and there is no cache.
func hasTypedData() bool {
if len(baselineServices()) > 0 {
return true
}
typedMu.RLock()
defer typedMu.RUnlock()
return len(remoteOverrides) > 0
}
// loadCachedTyped reads the on-disk remote cache, decodes it into the typed
// shape, and installs it as the remote overlay (typed replacement for the old
// map-based loadCachedMerged + overlay).
func loadCachedTyped() error {
data, err := vfs.ReadFile(cachePath())
if err != nil {
return err
}
var reg wireRegistry
if err := json.Unmarshal(data, &reg); err != nil {
// Cache corrupted — remove it so the next run triggers a fresh fetch.
_ = vfs.Remove(cachePath())
_ = vfs.Remove(cacheMetaPath())
return err
}
svcs := make([]metaschema.Service, 0, len(reg.Services))
for _, ws := range reg.Services {
svcs = append(svcs, wireToService(ws))
}
setRemoteOverrides(svcs)
return nil
}
// --- typed -> map[string]interface{} shim (lazy, per service, execution-path) ---
func strList(ss []string) []interface{} {
if len(ss) == 0 {
return nil
}
out := make([]interface{}, len(ss))
for i, s := range ss {
out[i] = s
}
return out
}
func fieldToMap(f metaschema.Field) map[string]interface{} {
m := map[string]interface{}{}
put := func(k, v string) {
if v != "" {
m[k] = v
}
}
put("type", f.Type)
put("location", f.Location)
put("description", f.Description)
put("default", f.Default)
put("example", f.Example)
put("enumName", f.EnumName)
put("min", f.Min)
put("max", f.Max)
put("ref", f.Ref)
if f.Required {
m["required"] = true
}
if v := strList(f.Enum); v != nil {
m["enum"] = v
}
if v := strList(f.Annotations); v != nil {
m["annotations"] = v
}
if len(f.Options) > 0 {
opts := make([]interface{}, len(f.Options))
for i, o := range f.Options {
opts[i] = map[string]interface{}{"value": o.Value, "description": o.Description}
}
m["options"] = opts
}
if len(f.Properties) > 0 {
m["properties"] = fieldsToMap(f.Properties)
}
return m
}
func fieldsToMap(fs []metaschema.Field) map[string]interface{} {
if len(fs) == 0 {
return nil
}
m := make(map[string]interface{}, len(fs))
for _, f := range fs {
m[f.Name] = fieldToMap(f)
}
return m
}
// affordanceToMap rebuilds the JSON-shaped affordance object (snake_case keys)
// so the schema assembler's parseAffordance(method["affordance"]) keeps working
// through the typed registry. Returns nil when the overlay carries nothing.
func affordanceToMap(a *metaschema.Affordance) map[string]interface{} {
m := map[string]interface{}{}
if v := strList(a.UseWhen); v != nil {
m["use_when"] = v
}
if v := strList(a.DoNotUseWhen); v != nil {
m["do_not_use_when"] = v
}
if v := strList(a.Prerequisites); v != nil {
m["prerequisites"] = v
}
if len(a.Examples) > 0 {
ex := make([]interface{}, len(a.Examples))
for i, e := range a.Examples {
ex[i] = map[string]interface{}{"description": e.Description, "command": e.Command}
}
m["examples"] = ex
}
if v := strList(a.Related); v != nil {
m["related"] = v
}
if len(m) == 0 {
return nil
}
return m
}
func MethodToMap(mth metaschema.Method) map[string]interface{} {
m := map[string]interface{}{
"id": mth.ID,
"path": mth.Path,
"httpMethod": mth.HTTPMethod,
"description": mth.Description,
}
if mth.Risk != "" {
m["risk"] = mth.Risk
}
if mth.DocURL != "" {
m["docUrl"] = mth.DocURL
}
if mth.Danger {
m["danger"] = true
}
if v := strList(mth.Scopes); v != nil {
m["scopes"] = v
}
if v := strList(mth.AccessTokens); v != nil {
m["accessTokens"] = v
}
if v := strList(mth.ParameterOrder); v != nil {
m["parameterOrder"] = v
}
if v := strList(mth.RequiredScopes); v != nil {
m["requiredScopes"] = v
}
if v := fieldsToMap(mth.Parameters); v != nil {
m["parameters"] = v
}
if v := fieldsToMap(mth.RequestBody); v != nil {
m["requestBody"] = v
}
if v := fieldsToMap(mth.ResponseBody); v != nil {
m["responseBody"] = v
}
if mth.Affordance != nil {
if am := affordanceToMap(mth.Affordance); am != nil {
m["affordance"] = am
}
}
return m
}
// ServiceToMap rebuilds the JSON-shaped map[string]interface{} for one service,
// so execution-path consumers (and method RunE) keep working unchanged.
func ServiceToMap(s metaschema.Service) map[string]interface{} {
resources := make(map[string]interface{}, len(s.Resources))
for _, r := range s.Resources {
methods := make(map[string]interface{}, len(r.Methods))
for _, mth := range r.Methods {
methods[mth.Name] = MethodToMap(mth)
}
resources[r.Name] = map[string]interface{}{"methods": methods}
}
return map[string]interface{}{
"name": s.Name,
"version": s.Version,
"title": s.Title,
"description": s.Description,
"servicePath": s.ServicePath,
"resources": resources,
}
}
// --- map[string]interface{} -> typed (for the map-based wrappers still used by
// tests; production builds from typed directly) ---
func ifaceStrs(v interface{}) []string {
raw, _ := v.([]interface{})
if len(raw) == 0 {
return nil
}
out := make([]string, 0, len(raw))
for _, e := range raw {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
}
func sortedMapKeys(m map[string]interface{}) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func mapToField(name string, m map[string]interface{}) metaschema.Field {
f := metaschema.Field{
Name: name, Type: GetStrFromMap(m, "type"), Location: GetStrFromMap(m, "location"),
Description: GetStrFromMap(m, "description"), Default: GetStrFromMap(m, "default"),
Example: GetStrFromMap(m, "example"), EnumName: GetStrFromMap(m, "enumName"),
Min: GetStrFromMap(m, "min"), Max: GetStrFromMap(m, "max"), Ref: GetStrFromMap(m, "ref"),
Enum: ifaceStrs(m["enum"]), Annotations: ifaceStrs(m["annotations"]),
}
if b, ok := m["required"].(bool); ok {
f.Required = b
}
if opts, ok := m["options"].([]interface{}); ok {
for _, o := range opts {
om, _ := o.(map[string]interface{})
f.Options = append(f.Options, metaschema.Option{Value: GetStrFromMap(om, "value"), Description: GetStrFromMap(om, "description")})
}
}
f.Properties = mapToFields(m["properties"])
return f
}
func mapToFields(v interface{}) []metaschema.Field {
fm, _ := v.(map[string]interface{})
if len(fm) == 0 {
return nil
}
out := make([]metaschema.Field, 0, len(fm))
for _, k := range sortedMapKeys(fm) {
em, _ := fm[k].(map[string]interface{})
out = append(out, mapToField(k, em))
}
return out
}
func MapToMethod(name string, m map[string]interface{}) metaschema.Method {
return metaschema.Method{
Name: name, ID: GetStrFromMap(m, "id"), Path: GetStrFromMap(m, "path"),
HTTPMethod: GetStrFromMap(m, "httpMethod"), Description: GetStrFromMap(m, "description"),
Risk: GetStrFromMap(m, "risk"), DocURL: GetStrFromMap(m, "docUrl"),
Danger: boolFromMap(m, "danger"),
Scopes: ifaceStrs(m["scopes"]),
AccessTokens: ifaceStrs(m["accessTokens"]),
ParameterOrder: ifaceStrs(m["parameterOrder"]),
RequiredScopes: ifaceStrs(m["requiredScopes"]),
Parameters: mapToFields(m["parameters"]),
RequestBody: mapToFields(m["requestBody"]),
ResponseBody: mapToFields(m["responseBody"]),
}
}
func boolFromMap(m map[string]interface{}, k string) bool {
b, _ := m[k].(bool)
return b
}
func MapToResources(v interface{}) []metaschema.Resource {
rm, _ := v.(map[string]interface{})
if len(rm) == 0 {
return nil
}
out := make([]metaschema.Resource, 0, len(rm))
for _, rk := range sortedMapKeys(rm) {
res, _ := rm[rk].(map[string]interface{})
mm, _ := res["methods"].(map[string]interface{})
methods := make([]metaschema.Method, 0, len(mm))
for _, mk := range sortedMapKeys(mm) {
methodMap, _ := mm[mk].(map[string]interface{})
methods = append(methods, MapToMethod(mk, methodMap))
}
out = append(out, metaschema.Resource{Name: rk, Methods: methods})
}
return out
}
// MapToService converts a JSON-shaped service spec (with embedded "resources")
// into the typed form.
func MapToService(spec map[string]interface{}) metaschema.Service {
return metaschema.Service{
Name: GetStrFromMap(spec, "name"), Version: GetStrFromMap(spec, "version"),
Title: GetStrFromMap(spec, "title"), Description: GetStrFromMap(spec, "description"),
ServicePath: GetStrFromMap(spec, "servicePath"), Resources: MapToResources(spec["resources"]),
}
}
// --- remote JSON (wire) -> typed ---
type wireRegistry struct {
Version string `json:"version"`
Services []wireService `json:"services"`
}
type wireService struct {
Name string `json:"name"`
Version string `json:"version"`
Title string `json:"title"`
Description string `json:"description"`
ServicePath string `json:"servicePath"`
Resources map[string]wireResource `json:"resources"`
}
type wireResource struct {
Methods map[string]wireMethod `json:"methods"`
}
type wireMethod struct {
ID string `json:"id"`
Path string `json:"path"`
HTTPMethod string `json:"httpMethod"`
Description string `json:"description"`
Risk string `json:"risk"`
DocURL string `json:"docUrl"`
Danger bool `json:"danger"`
Scopes []string `json:"scopes"`
AccessTokens []string `json:"accessTokens"`
ParameterOrder []string `json:"parameterOrder"`
RequiredScopes []string `json:"requiredScopes"`
Parameters map[string]wireField `json:"parameters"`
RequestBody map[string]wireField `json:"requestBody"`
ResponseBody map[string]wireField `json:"responseBody"`
}
type wireField struct {
Type string `json:"type"`
Location string `json:"location"`
Description string `json:"description"`
Default string `json:"default"`
Example string `json:"example"`
EnumName string `json:"enumName"`
Min string `json:"min"`
Max string `json:"max"`
Ref string `json:"ref"`
Required bool `json:"required"`
Options []metaschema.Option `json:"options"`
Enum []string `json:"enum"`
Annotations []string `json:"annotations"`
Properties map[string]wireField `json:"properties"`
}
func sortedFieldKeys(m map[string]wireField) []string {
ks := make([]string, 0, len(m))
for k := range m {
ks = append(ks, k)
}
sort.Strings(ks)
return ks
}
func wireFields(m map[string]wireField) []metaschema.Field {
if len(m) == 0 {
return nil
}
out := make([]metaschema.Field, 0, len(m))
for _, name := range sortedFieldKeys(m) {
wf := m[name]
out = append(out, metaschema.Field{
Name: name, Type: wf.Type, Location: wf.Location, Description: wf.Description,
Default: wf.Default, Example: wf.Example, EnumName: wf.EnumName,
Min: wf.Min, Max: wf.Max, Ref: wf.Ref, Required: wf.Required,
Options: wf.Options, Enum: wf.Enum, Annotations: wf.Annotations,
Properties: wireFields(wf.Properties),
})
}
return out
}
func wireToService(ws wireService) metaschema.Service {
resKeys := make([]string, 0, len(ws.Resources))
for k := range ws.Resources {
resKeys = append(resKeys, k)
}
sort.Strings(resKeys)
resources := make([]metaschema.Resource, 0, len(resKeys))
for _, rk := range resKeys {
wr := ws.Resources[rk]
methKeys := make([]string, 0, len(wr.Methods))
for k := range wr.Methods {
methKeys = append(methKeys, k)
}
sort.Strings(methKeys)
methods := make([]metaschema.Method, 0, len(methKeys))
for _, mk := range methKeys {
wm := wr.Methods[mk]
methods = append(methods, metaschema.Method{
Name: mk, ID: wm.ID, Path: wm.Path, HTTPMethod: wm.HTTPMethod,
Description: wm.Description, Risk: wm.Risk, DocURL: wm.DocURL, Danger: wm.Danger,
Scopes: wm.Scopes, AccessTokens: wm.AccessTokens,
ParameterOrder: wm.ParameterOrder, RequiredScopes: wm.RequiredScopes,
Parameters: wireFields(wm.Parameters), RequestBody: wireFields(wm.RequestBody),
ResponseBody: wireFields(wm.ResponseBody),
})
}
resources = append(resources, metaschema.Resource{Name: rk, Methods: methods})
}
return metaschema.Service{
Name: ws.Name, Version: ws.Version, Title: ws.Title,
Description: ws.Description, ServicePath: ws.ServicePath, Resources: resources,
}
}

View File

@@ -4,290 +4,14 @@
package schema
import (
"bytes"
"encoding/json"
"sort"
"strconv"
"sync"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/registry"
)
// MethodKeyOrder records the natural meta_data.json key order for one method's
// parameters / requestBody / responseBody. Nested object key orders are stored
// under NestedKeys, keyed by dotted path from the method root
// (e.g. "responseBody.items.properties").
type MethodKeyOrder struct {
Parameters []string
RequestBody []string
ResponseBody []string
NestedKeys map[string][]string
}
var (
keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order
keyOrderInitOnce sync.Once
)
// lookupKeyOrder returns the key-order record for service.resourcePath.method,
// or nil if the method is not in the embedded data (e.g. remote-cached).
func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder {
keyOrderInitOnce.Do(buildKeyOrderIndex)
if keyOrderIndex == nil {
return nil
}
dotted := dottedPath(service, resourcePath, method)
return keyOrderIndex[dotted]
}
func dottedPath(service string, resourcePath []string, method string) string {
var buf bytes.Buffer
buf.WriteString(service)
for _, r := range resourcePath {
buf.WriteByte('.')
buf.WriteString(r)
}
buf.WriteByte('.')
buf.WriteString(method)
return buf.String()
}
// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init,
// walking services -> resources -> methods -> {parameters,requestBody,responseBody}
// and recording each map's key insertion order via json.Decoder.Token().
func buildKeyOrderIndex() {
raw := registry.EmbeddedMetaJSON()
if len(raw) == 0 {
return
}
keyOrderIndex = make(map[string]*MethodKeyOrder)
dec := json.NewDecoder(bytes.NewReader(raw))
// Top-level: { "services": [...], "version": "..." }
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
if key != "services" {
skipValue(dec)
continue
}
if !expectDelim(dec, '[') {
return
}
for dec.More() {
parseService(dec)
}
// closing ]
_, _ = dec.Token()
}
}
// parseService consumes one service object inside services[].
// meta_data.json may emit "resources" before "name", so we first capture both
// raw fields, then walk resources with the resolved service name.
func parseService(dec *json.Decoder) {
if !expectDelim(dec, '{') {
return
}
var serviceName string
var resourcesRaw json.RawMessage
for dec.More() {
key, _ := readKey(dec)
switch key {
case "name":
tok, _ := dec.Token()
if s, ok := tok.(string); ok {
serviceName = s
}
case "resources":
if err := dec.Decode(&resourcesRaw); err != nil {
skipValue(dec)
}
default:
skipValue(dec)
}
}
_, _ = dec.Token() // closing }
if serviceName != "" && len(resourcesRaw) > 0 {
subDec := json.NewDecoder(bytes.NewReader(resourcesRaw))
parseResources(subDec, serviceName, nil)
}
}
// parseResources walks a resources map (resName -> resource object).
// resourcePath is the accumulated path of parent resources (for nested resources).
func parseResources(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
resName, _ := readKey(dec)
parseResourceObj(dec, service, append(resourcePath, resName))
}
_, _ = dec.Token()
}
// parseResourceObj consumes one resource value: { methods: {...}, ... } and may
// recurse into nested resources via "resources" key if present.
func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "methods":
parseMethods(dec, service, resourcePath)
case "resources":
parseResources(dec, service, resourcePath)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
}
// parseMethods consumes the methods map (methodName -> method object).
func parseMethods(dec *json.Decoder, service string, resourcePath []string) {
if !expectDelim(dec, '{') {
return
}
for dec.More() {
methodName, _ := readKey(dec)
mko := parseMethod(dec)
dotted := dottedPath(service, resourcePath, methodName)
keyOrderIndex[dotted] = mko
}
_, _ = dec.Token()
}
// parseMethod consumes one method object and records key orders.
func parseMethod(dec *json.Decoder) *MethodKeyOrder {
mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)}
if !expectDelim(dec, '{') {
return mko
}
for dec.More() {
key, _ := readKey(dec)
switch key {
case "parameters":
mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys)
case "requestBody":
mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys)
case "responseBody":
mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys)
default:
skipValue(dec)
}
}
_, _ = dec.Token()
return mko
}
// recordObjectKeysRecursive consumes an object and records the top-level key
// order. It also recurses into each child's "properties" submap, recording
// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys
// in order.
func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string {
if !expectDelim(dec, '{') {
return nil
}
var order []string
for dec.More() {
key, _ := readKey(dec)
order = append(order, key)
// Each child value is itself an object; we want its nested "properties" order if present.
consumeFieldRecursive(dec, prefix+"."+key, nestedKeys)
}
_, _ = dec.Token()
if prefix != "" && len(order) > 0 {
nestedKeys[prefix] = order
}
return order
}
// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and,
// if it contains "properties": {...}, recursively records that submap's order.
func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) {
tok, err := dec.Token()
if err != nil {
return
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
// Not an object — skip the rest of the value
skipValueAfterToken(dec, tok)
return
}
for dec.More() {
fieldKey, _ := readKey(dec)
if fieldKey == "properties" {
recordObjectKeysRecursive(dec, path+".properties", nestedKeys)
} else {
skipValue(dec)
}
}
_, _ = dec.Token()
}
// --- json.Decoder helpers ---
func expectDelim(dec *json.Decoder, want json.Delim) bool {
tok, err := dec.Token()
if err != nil {
return false
}
delim, ok := tok.(json.Delim)
return ok && delim == want
}
func readKey(dec *json.Decoder) (string, error) {
tok, err := dec.Token()
if err != nil {
return "", err
}
s, _ := tok.(string)
return s, nil
}
// skipValue consumes the next complete value (scalar, object, or array).
func skipValue(dec *json.Decoder) {
tok, err := dec.Token()
if err != nil {
return
}
skipValueAfterToken(dec, tok)
}
func skipValueAfterToken(dec *json.Decoder, tok json.Token) {
delim, ok := tok.(json.Delim)
if !ok {
return
}
// We started inside a container of type `delim` ({ or [) and must eat
// tokens until that container closes, tracking nested containers of any
// kind. depth counts how many open containers we are currently inside.
_ = delim
depth := 1
for depth > 0 {
t, err := dec.Token()
if err != nil {
return
}
if d, ok := t.(json.Delim); ok {
switch d {
case '{', '[':
depth++
case '}', ']':
depth--
}
}
}
}
// coerceLiteral converts a meta_data literal (default / enum / example) to
// the JSON Schema type declared by the field (integer/number/boolean/string).
// meta_data stores every literal as a string, so without coercion an
@@ -501,10 +225,6 @@ func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedP
return op, required
}
// currentMethodOrder is the per-method key-order context used by orderedKeys.
// It is set inside AssembleEnvelope (under assembleMu) and reset on return.
var currentMethodOrder *MethodKeyOrder
// parseAffordance lifts the affordance overlay from a method's raw meta_data.json
// entry into a typed *Affordance. Returns nil when the field is absent, malformed,
// or carries no populated subfields.
@@ -611,8 +331,6 @@ func buildMeta(method map[string]interface{}) *Meta {
// The params / data wrapping mirrors the CLI's actual flag layout:
// path+query → --params JSON, body → --data JSON, file → --file. AI consumers
// can pluck inputSchema.properties.params and pass it verbatim to --params.
//
// Caller must set currentMethodOrder for property-order preservation.
func buildInputSchema(method map[string]interface{}) *InputSchema {
is := &InputSchema{
Type: "object",
@@ -738,27 +456,11 @@ func buildOutputSchema(method map[string]interface{}) *OutputSchema {
return os
}
// assembleMu serializes AssembleEnvelope calls so that the package-level
// currentMethodOrder pointer is safe for concurrent callers.
var assembleMu sync.Mutex
// AssembleEnvelope is the main entry point: takes a service / resource path /
// method name plus its meta_data spec, and produces a fully assembled MCP
// envelope. Output is fully determined by inputs (same arguments → same
// envelope), but assembly briefly publishes the per-method key-order context
// through the package-level currentMethodOrder so orderedKeys can reach it
// without threading it through every helper. assembleMu serializes that
// publish, which is why concurrent callers are still safe — they queue
// rather than run in parallel.
//
// If parallelism becomes a bottleneck, replace currentMethodOrder with an
// assembler struct or pass *MethodKeyOrder explicitly down the call chain.
// envelope).
func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope {
assembleMu.Lock()
defer assembleMu.Unlock()
currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName)
defer func() { currentMethodOrder = nil }()
name := serviceName
for _, r := range resourcePath {
name += " " + r
@@ -836,35 +538,10 @@ func walkMethods(resources map[string]interface{}, parentPath []string,
}
}
// orderedKeys returns the keys of raw in their meta_data natural order if
// the current per-method key-order context has them recorded; otherwise
// alphabetical fallback.
func orderedKeys(raw map[string]interface{}, nestedPath string) []string {
if currentMethodOrder != nil && nestedPath != "" {
if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok {
// Filter to keys that actually exist in raw (defensive)
out := make([]string, 0, len(order))
seen := make(map[string]bool)
for _, k := range order {
if _, ok := raw[k]; ok {
out = append(out, k)
seen[k] = true
}
}
// Append any keys present in raw but missing from order (defensive),
// alphabetically for determinism.
var extra []string
for k := range raw {
if !seen[k] {
extra = append(extra, k)
}
}
sort.Strings(extra)
out = append(out, extra...)
return out
}
}
// Fallback: alphabetical
// orderedKeys returns the keys of raw in alphabetical order. Field display
// order is not preserved: the schema envelope is consumed as a JSON Schema (MCP
// tool spec), where object property order carries no meaning.
func orderedKeys(raw map[string]interface{}, _ string) []string {
keys := make([]string, 0, len(raw))
for k := range raw {
keys = append(keys, k)

View File

@@ -7,10 +7,12 @@ import (
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/registry/metaschema"
)
// TestMain isolates registry-backed tests from any host ~/.lark-cli cache so
@@ -35,58 +37,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestKeyOrderIndex_ImReactionsList(t *testing.T) {
// We only assert key-set membership, not absolute order — the upstream
// meta_data API does not guarantee a stable JSON key sequence across
// fetches, so hard-coding the order makes CI flaky. Order preservation
// from input to output is tested separately in TestBuildInputSchema_*.
order := lookupKeyOrder("im", []string{"reactions"}, "list")
if order == nil {
t.Fatal("expected key order for im.reactions.list, got nil")
}
wantParams := map[string]bool{
"message_id": true, "reaction_type": true, "page_token": true,
"page_size": true, "user_id_type": true,
}
if got, want := len(order.Parameters), len(wantParams); got != want {
t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters)
}
for _, k := range order.Parameters {
if !wantParams[k] {
t.Errorf("unexpected parameter key %q", k)
}
}
// im.reactions.list 是 GET没有 requestBody
if len(order.RequestBody) != 0 {
t.Errorf("expected empty RequestBody, got %v", order.RequestBody)
}
}
func TestKeyOrderIndex_ImImagesCreate(t *testing.T) {
// Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList.
order := lookupKeyOrder("im", []string{"images"}, "create")
if order == nil {
t.Fatal("expected key order for im.images.create, got nil")
}
wantBody := map[string]bool{"image_type": true, "image": true}
if got, want := len(order.RequestBody), len(wantBody); got != want {
t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody)
}
for _, k := range order.RequestBody {
if !wantBody[k] {
t.Errorf("unexpected requestBody key %q", k)
}
}
}
func TestKeyOrderIndex_UnknownPath(t *testing.T) {
// 远端缓存的命令(不在 embedded 内)查不到 key order返回 nil 走字母序兜底
order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar")
if order != nil {
t.Errorf("expected nil for unknown path, got %+v", order)
}
}
func TestConvertProperty_BasicTypes(t *testing.T) {
tests := []struct {
name string
@@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) {
func TestBuildInputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -313,16 +260,15 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) {
if !reflect.DeepEqual(params.Required, []string{"message_id"}) {
t.Errorf("params.Required = %v, want [message_id]", params.Required)
}
if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) {
t.Errorf("params.properties order = %v, want (from key index) %v",
params.Properties.Order, mko.Parameters)
// Property order is alphabetical now: the envelope is a JSON Schema (MCP
// tool spec) where object property order carries no meaning.
if !sort.StringsAreSorted(params.Properties.Order) {
t.Errorf("params.properties order not alphabetical: %v", params.Properties.Order)
}
}
func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"images"}, "create")
currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create")
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
@@ -382,9 +328,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
},
},
}
currentMethodOrder = nil
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
// yes lives at inputSchema.properties.yes (sibling of params/data)
@@ -413,9 +356,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) {
func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
is := buildInputSchema(method)
if _, ok := is.Properties.Map["yes"]; ok {
@@ -425,9 +365,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) {
func TestBuildOutputSchema_ReactionsList(t *testing.T) {
method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list")
mko := lookupKeyOrder("im", []string{"reactions"}, "list")
currentMethodOrder = mko
defer func() { currentMethodOrder = nil }()
os := buildOutputSchema(method)
@@ -613,6 +550,45 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
}
}
// TestBuildMeta_AffordanceThroughTypedRegistry guards the static-registry path:
// a method's affordance must survive metaschema.Method -> registry.MethodToMap
// -> buildMeta, so `schema --format json` keeps emitting _meta.affordance after
// the embedded-JSON-to-typed-registry migration. Without typed-side support the
// overlay is silently stripped whenever meta_data.json carries affordance.
func TestBuildMeta_AffordanceThroughTypedRegistry(t *testing.T) {
mth := metaschema.Method{
Name: "primary",
Affordance: &metaschema.Affordance{
UseWhen: []string{"用户想拿到自己默认日历的 ID"},
DoNotUseWhen: []string{"已经知道某个具体日历的 ID"},
Prerequisites: []string{"user 身份登录"},
Examples: []metaschema.AffordanceExample{
{Description: "取主日历", Command: "lark-cli calendar calendars primary"},
},
Related: []string{"calendars.list", "calendars.get"},
},
}
method := registry.MethodToMap(mth)
m := buildMeta(method)
if m.Affordance == nil {
t.Fatal("affordance dropped through the typed registry (MethodToMap -> buildMeta)")
}
a := m.Affordance
if len(a.UseWhen) != 1 || a.UseWhen[0] != "用户想拿到自己默认日历的 ID" {
t.Errorf("UseWhen = %v", a.UseWhen)
}
if len(a.DoNotUseWhen) != 1 || len(a.Prerequisites) != 1 {
t.Errorf("DoNotUseWhen=%v Prerequisites=%v", a.DoNotUseWhen, a.Prerequisites)
}
if len(a.Examples) != 1 || a.Examples[0].Description != "取主日历" ||
a.Examples[0].Command != "lark-cli calendar calendars primary" {
t.Errorf("Examples = %+v", a.Examples)
}
if len(a.Related) != 2 {
t.Errorf("Related = %v", a.Related)
}
}
func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
method := map[string]interface{}{
"scopes": []interface{}{"x"},
@@ -634,7 +610,6 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) {
func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) {
// 装配器对空 responseBody 应生成 properties = {} (不 nil
method := map[string]interface{}{}
currentMethodOrder = nil
os := buildOutputSchema(method)
if os.Type != "object" {
t.Errorf("Type = %q, want \"object\"", os.Type)

View File

@@ -83,9 +83,13 @@ type AffordanceCase struct {
Command string `json:"command"`
}
// OrderedProps is map[string]Property with preserved key order on MarshalJSON.
// It is used wherever JSON output must reflect meta_data.json's natural field
// order rather than Go's default alphabetical map encoding.
// OrderedProps is map[string]Property that emits its keys in Order on
// MarshalJSON. Order is now populated alphabetically (see orderedKeys): the
// schema envelope is an MCP tool spec / JSON Schema, where object property
// order carries no meaning. The machinery that once preserved meta_data.json's
// natural field order was removed with the static-registry migration; Order is
// retained so MarshalJSON has one stable key sequence (and callers that leave
// it empty fall back to alphabetical over Map).
type OrderedProps struct {
Order []string
Map map[string]Property

View File

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

View File

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

View File

@@ -0,0 +1,209 @@
// 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

@@ -0,0 +1,290 @@
// 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

@@ -80,6 +80,30 @@ func ParseGlobalSkillsJSON(text string) []string {
return sortedKeys(seen)
}
func ParseOfficialSkillsIndexJSON(text string) ([]string, error) {
type officialSkill struct {
Name string `json:"name"`
}
type officialIndex struct {
Skills []officialSkill `json:"skills"`
}
var index officialIndex
if err := json.Unmarshal([]byte(text), &index); err != nil {
return nil, err
}
seen := map[string]bool{}
for _, skill := range index.Skills {
candidate := strings.TrimSpace(skill.Name)
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
return sortedKeys(seen), nil
}
// parseGlobalSkillsList parses the output of "npx -y skills ls -g"
func parseGlobalSkillsList(lines []string) []string {
seen := map[string]bool{}
@@ -160,8 +184,7 @@ func parseOfficialSkillsList(lines []string) []string {
if len(parts) > 0 {
candidate := parts[0]
// Check if it's a valid official skill name
if strings.HasPrefix(candidate, "lark-") && skillNamePattern.MatchString(candidate) {
if skillNamePattern.MatchString(candidate) {
seen[candidate] = true
}
}
@@ -223,6 +246,7 @@ func PlanSync(input SyncInput) SyncPlan {
}
type SkillsRunner interface {
ListOfficialSkillsIndex() *selfupdate.NpmResult
ListOfficialSkills() *selfupdate.NpmResult
ListGlobalSkillsJSON() *selfupdate.NpmResult
ListGlobalSkills() *selfupdate.NpmResult
@@ -258,14 +282,9 @@ func SyncSkills(opts SyncOptions) *SyncResult {
}
// --- Step 1: List official skills ---
officialResult := opts.Runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
return fallbackFullInstall(opts, resultDetail(officialResult), nil)
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) == 0 && strings.TrimSpace(officialResult.Stdout.String()) != "" {
return fallbackFullInstall(opts, "official skills list parsed as empty despite non-empty stdout", nil)
official, reason, ok := listOfficialSkills(opts.Runner)
if !ok {
return fallbackFullInstall(opts, reason, nil)
}
// --- Step 2: List local (installed) skills ---
@@ -327,6 +346,40 @@ func SyncSkills(opts SyncOptions) *SyncResult {
return result
}
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
reasons := []string{}
indexResult := runner.ListOfficialSkillsIndex()
if indexResult == nil || indexResult.Err != nil {
reasons = append(reasons, "official skills index failed: "+resultDetail(indexResult))
} else {
official, err := ParseOfficialSkillsIndexJSON(indexResult.Stdout.String())
if err != nil {
reasons = append(reasons, "official skills index JSON invalid: "+err.Error())
} else if len(official) > 0 {
return official, "", true
} else {
reasons = append(reasons, "official skills index contains no skills")
}
}
officialResult := runner.ListOfficialSkills()
if officialResult == nil || officialResult.Err != nil {
reasons = append(reasons, "official skills list failed: "+resultDetail(officialResult))
return nil, strings.Join(reasons, "; "), false
}
official := ParseSkillsList(officialResult.Stdout.String())
if len(official) > 0 {
return official, "", true
}
if strings.TrimSpace(officialResult.Stdout.String()) != "" {
reasons = append(reasons, "official skills list parsed as empty despite non-empty stdout")
} else {
reasons = append(reasons, "official skills list returned no skills")
}
return nil, strings.Join(reasons, "; "), false
}
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
jsonResult := runner.ListGlobalSkillsJSON()
if jsonResult != nil && jsonResult.Err == nil {

View File

@@ -30,6 +30,19 @@ lark-cli-harness:dev@0.1.0
}
}
func TestParseOfficialSkillsListAcceptsNonLarkOfficialNames(t *testing.T) {
input := `Available Skills
│ lark-calendar
│ official-shared
│ bad/name
`
got := ParseSkillsList(input)
want := []string{"lark-calendar", "official-shared"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseSkillsList() (Available Skills) = %#v, want %#v", got, want)
}
}
func TestParseGlobalSkillsList(t *testing.T) {
input := `Global Skills
@@ -110,6 +123,43 @@ func TestParseGlobalSkillsJSONInvalidOrUnsupported(t *testing.T) {
}
}
func TestParseOfficialSkillsIndexJSON(t *testing.T) {
input := `{
"skills": [
{"name":"lark-calendar","description":"Calendar","files":["SKILL.md"]},
{"name":"lark-mail","description":"Mail","files":["SKILL.md","references/lark-mail-search.md"]},
{"name":" lark-base ","description":"Base","files":[]},
{"name":"lark-calendar","description":"duplicate","files":["SKILL.md"]},
{"name":"custom-skill","description":"not official","files":["SKILL.md"]},
{"name":"bad skill","description":"invalid","files":["SKILL.md"]},
{"name":"","description":"empty","files":["SKILL.md"]}
]
}`
got, err := ParseOfficialSkillsIndexJSON(input)
if err != nil {
t.Fatalf("ParseOfficialSkillsIndexJSON() err = %v, want nil", err)
}
want := []string{"custom-skill", "lark-base", "lark-calendar", "lark-mail"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ParseOfficialSkillsIndexJSON() = %#v, want %#v", got, want)
}
}
func TestParseOfficialSkillsIndexJSONInvalidOrUnsupported(t *testing.T) {
for _, input := range []string{
`not json`,
`[{"name":"lark-calendar"}]`,
`{"name":"lark-calendar"}`,
`{"skills":[]}`,
`{"skills":[{"name":"bad skill"}]}`,
} {
got, err := ParseOfficialSkillsIndexJSON(input)
if err == nil && len(got) != 0 {
t.Fatalf("ParseOfficialSkillsIndexJSON(%q) = %#v, want empty", input, got)
}
}
}
func TestPlanNormal_WithReadableStatePreservesDeletedAndAddsNew(t *testing.T) {
previous := &SkillsState{OfficialSkills: []string{"lark-calendar", "lark-mail"}}
got := PlanSync(SyncInput{
@@ -156,9 +206,11 @@ func TestPlanForceRestoresAllOfficial(t *testing.T) {
}
type fakeSkillsRunner struct {
officialIndexOut string
officialOut string
globalJSONOut string
globalOut string
officialIndexErr error
officialErr error
globalJSONErr error
globalErr error
@@ -166,6 +218,8 @@ type fakeSkillsRunner struct {
installAllErr error
installed [][]string
installedAll int
listedIndex int
listedOfficial int
listedGlobalJSON int
listedGlobalText int
}
@@ -181,6 +235,19 @@ func officialSkillsOutput(names ...string) string {
return b.String()
}
func officialSkillsIndexOutput(names ...string) string {
var b strings.Builder
b.WriteString(`{"skills":[`)
for i, name := range names {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"name":%q,"description":"test skill","files":["SKILL.md"]}`, name)
}
b.WriteString(`]}`)
return b.String()
}
func globalSkillsOutput(names ...string) string {
var b strings.Builder
b.WriteString("Global Skills\n\n")
@@ -206,7 +273,16 @@ func globalSkillsJSONOutput(names ...string) string {
return b.String()
}
func (f *fakeSkillsRunner) ListOfficialSkillsIndex() *selfupdate.NpmResult {
f.listedIndex++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialIndexOut)
r.Err = f.officialIndexErr
return r
}
func (f *fakeSkillsRunner) ListOfficialSkills() *selfupdate.NpmResult {
f.listedOfficial++
r := &selfupdate.NpmResult{}
r.Stdout.WriteString(f.officialOut)
r.Err = f.officialErr
@@ -255,9 +331,10 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail", "lark-new"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-custom"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{
Version: "1.0.33",
@@ -289,12 +366,119 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
}
}
func TestSyncSkills_OfficialIndexSuccessSkipsOfficialListCommand(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail", "lark-new"),
officialOut: officialSkillsOutput("lark-should-not-be-used"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
globalOut: globalSkillsOutput("lark-mail"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail", "lark-new"})
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail", "lark-new"})
if runner.listedIndex != 1 {
t.Fatalf("listedIndex = %d, want 1", runner.listedIndex)
}
if runner.listedOfficial != 0 {
t.Fatalf("listedOfficial = %d, want 0 when index succeeds", runner.listedOfficial)
}
}
func TestSyncSkills_OfficialIndexFailureFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
if runner.installedAll != 0 {
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
}
}
func TestSyncSkills_OfficialIndexEmptyFallsBackToOfficialList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Err != nil {
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
}
assertStrings(t, result.Official, []string{"lark-calendar", "lark-mail"})
if runner.listedIndex != 1 || runner.listedOfficial != 1 {
t.Fatalf("listed index/official = %d/%d, want 1/1", runner.listedIndex, runner.listedOfficial)
}
}
func TestSyncSkills_OfficialDiscoveryFailuresFallBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index failed") || !strings.Contains(result.Detail, "official skills list failed") {
t.Fatalf("SyncSkills() detail = %q, want both discovery failure reasons", result.Detail)
}
}
func TestSyncSkills_OfficialDiscoveryEmptyFallsBackToFullInstallWithReasons(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialIndexOut: `{"skills":[]}`,
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
if !strings.Contains(result.Detail, "official skills index contains no skills") || !strings.Contains(result.Detail, "official skills list returned no skills") {
t.Fatalf("SyncSkills() detail = %q, want both empty discovery reasons", result.Detail)
}
}
func TestSyncSkills_ListOfficialFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -322,8 +506,9 @@ func TestSyncSkills_ListOfficialFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -342,9 +527,10 @@ func TestSyncSkills_GlobalJSONFailureFallsBackToTextList(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed"),
globalOut: globalSkillsOutput("lark-calendar"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -367,9 +553,10 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONErr: fmt.Errorf("json list failed with /Users/example/.agents/skills/lark-calendar agents Codex"),
globalErr: fmt.Errorf("text list failed with /Users/example/.agents/skills/lark-mail agents Codex"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -391,9 +578,10 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: `[]`,
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -420,9 +608,10 @@ func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {
}
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput(),
installAllErr: nil,
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})
@@ -445,11 +634,12 @@ func TestSyncSkills_InstallFailureFallsBackToFullInstall(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -477,11 +667,12 @@ func TestSyncSkills_InstallFailureAndFullInstallFails(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: fmt.Errorf("full install boom"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -510,8 +701,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -527,8 +719,9 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: fmt.Errorf("full install failed"),
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -551,8 +744,9 @@ func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T)
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialOut: "Some unrecognized output format\n",
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -576,11 +770,12 @@ func TestSyncSkills_FallbackWithKnownOfficialWritesFullState(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -601,11 +796,12 @@ func TestSyncSkills_FallbackResultContainsMetadata(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
installErr: fmt.Errorf("incremental boom"),
installAllErr: nil,
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -625,8 +821,9 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
dir := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
runner := &fakeSkillsRunner{
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
officialIndexErr: fmt.Errorf("index unavailable"),
officialErr: fmt.Errorf("list failed"),
installAllErr: nil,
}
result1 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
@@ -643,9 +840,10 @@ func TestSyncSkills_FallbackBreaksDegradationLoop(t *testing.T) {
}
runner2 := &fakeSkillsRunner{
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-mail"),
officialOut: officialSkillsOutput("lark-calendar", "lark-mail"),
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-mail"),
globalOut: globalSkillsOutput("lark-calendar", "lark-mail"),
}
result2 := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner2, Now: time.Now})
if result2.Action != "synced" {

View File

@@ -19,8 +19,10 @@ var migratedCommonHelperPaths = []string{
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
}

View File

@@ -20,8 +20,10 @@ var migratedEnvelopePaths = []string{
"shortcuts/calendar/",
"shortcuts/drive/",
"shortcuts/mail/",
"shortcuts/minutes/",
"shortcuts/okr/",
"shortcuts/task/",
"shortcuts/vc/",
"shortcuts/whiteboard/",
"shortcuts/im/",
}

View File

@@ -6,6 +6,7 @@ OUT_DIR="$ROOT_DIR/.pkg-pr-new"
cd "$ROOT_DIR"
# fetch_meta.py also regenerates the static Go registry (meta_data_gen.go).
python3 scripts/fetch_meta.py
rm -rf "$OUT_DIR"

View File

@@ -63,6 +63,19 @@ def fetch_remote(brand):
return data
def run_gen():
"""Regenerate the static Go registry (metastatic/meta_data_gen.go) from
meta_data.json. Run after every fetch so any caller that fetches also
produces the sole build-time source of the embedded command tree — no build
tag, no JSON embedded in the binary. Output is gitignored."""
print("fetch-meta: generating static Go registry (metastatic/meta_data_gen.go)", file=sys.stderr)
subprocess.run(
["go", "run", "internal/registry/metastatic/gen.go"],
cwd=ROOT,
check=True,
)
def main():
parser = argparse.ArgumentParser(description="Fetch meta_data.json for build-time embedding")
parser.add_argument("--brand", default="feishu", choices=["feishu", "lark"],
@@ -71,27 +84,29 @@ def main():
help="force refresh from remote even if local file exists")
args = parser.parse_args()
if os.path.exists(OUT_PATH) and not args.force:
if os.path.isfile(OUT_PATH):
try:
with open(OUT_PATH, "r", encoding="utf-8") as fp:
local = json.load(fp)
if local.get("services"):
print(f"fetch-meta: {OUT_PATH} already exists, skipping (use --force to re-fetch)", file=sys.stderr)
return
print(f"fetch-meta: {OUT_PATH} has no services, re-fetching", file=sys.stderr)
except (OSError, json.JSONDecodeError):
print(f"fetch-meta: {OUT_PATH} is invalid JSON, re-fetching", file=sys.stderr)
else:
print(f"fetch-meta: {OUT_PATH} is not a file, re-fetching", file=sys.stderr)
have_valid = False
if os.path.isfile(OUT_PATH) and not args.force:
try:
with open(OUT_PATH, "r", encoding="utf-8") as fp:
local = json.load(fp)
have_valid = bool(local.get("services"))
except (OSError, json.JSONDecodeError):
have_valid = False
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
if have_valid:
print(f"fetch-meta: {OUT_PATH} already exists, skipping fetch (use --force to re-fetch)", file=sys.stderr)
else:
data = fetch_remote(args.brand)
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")
with open(OUT_PATH, "w") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")
# Always (re)generate the static Go registry so every fetch also produces
# the embedded command tree — the build-time replacement for the old
# embedded meta_data.json.
run_gen()
if __name__ == "__main__":

View File

@@ -23,7 +23,7 @@ func FetchDriveMeta(runtime *RuntimeContext, token, docType string, withURL bool
body["with_url"] = true
}
data, err := runtime.CallAPI(
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,

View File

@@ -10,6 +10,7 @@ import (
"sync/atomic"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -103,6 +104,13 @@ func TestFetchDriveMetaTitle(t *testing.T) {
if err == nil {
t.Fatal("FetchDriveMetaTitle() expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if p.Code != 99991668 {
t.Fatalf("code = %d, want 99991668", p.Code)
}
})
}

View File

@@ -106,25 +106,6 @@ func ExactlyOneTyped(rt *RuntimeContext, flags ...string) error {
return MutuallyExclusiveTyped(rt, flags...)
}
// ValidatePageSize validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
//
// Deprecated: use ValidatePageSizeTyped for typed error envelopes.
func ValidatePageSize(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {
s := rt.Str(flagName)
if s == "" {
return defaultVal, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, FlagErrorf("invalid --%s %q: must be an integer", flagName, s)
}
if n < minVal || n > maxVal {
return 0, FlagErrorf("invalid --%s %d: must be between %d and %d", flagName, n, minVal, maxVal)
}
return n, nil
}
// ValidatePageSizeTyped validates that the named flag (if set) is an integer within [minVal, maxVal].
// It returns the parsed value (or defaultVal if the flag is empty) and any validation error.
func ValidatePageSizeTyped(rt *RuntimeContext, flagName string, defaultVal, minVal, maxVal int) (int, error) {

View File

@@ -5,35 +5,13 @@ package doc
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
// v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands.
func v1CreateFlags() []common.Flag {
return []common.Flag{
{Name: "title", Desc: "document title", Hidden: true},
{Name: "markdown", Desc: "Markdown content (Lark-flavored)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "folder-token", Desc: "parent folder token", Hidden: true},
{Name: "wiki-node", Desc: "wiki node token", Hidden: true},
{Name: "wiki-space", Desc: "wiki space ID (use my_library for personal library)", Hidden: true},
}
}
var docsCreateFlagVersions = buildFlagVersionMap(v1CreateFlags(), v2CreateFlags())
// useV2Create returns true when the v2 (OpenAPI) create path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Create(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("content") != "" ||
runtime.Str("parent-token") != "" ||
runtime.Str("parent-position") != ""
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
}
var DocsCreate = common.Shortcut{
@@ -43,213 +21,25 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
Tips: docsVersionSelectionTips,
PostMount: installDocsShortcutHelp("+create"),
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
docsAPIVersionCompatFlag(),
},
v1CreateFlags(),
v2CreateFlags(),
v1CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
}
return validateCreateV1(ctx, runtime)
return validateCreateV2(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
}
return dryRunCreateV1(ctx, runtime)
return dryRunCreateV2(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
}
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
return executeCreateV2(ctx, runtime)
},
}
// ── V1 (MCP) implementation ──
func validateCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Str("markdown") == "" {
return common.FlagErrorf("--markdown is required")
}
count := 0
if runtime.Str("folder-token") != "" {
count++
}
if runtime.Str("wiki-node") != "" {
count++
}
if runtime.Str("wiki-space") != "" {
count++
}
if count > 1 {
return common.FlagErrorf("--folder-token, --wiki-node, and --wiki-space are mutually exclusive")
}
return nil
}
func dryRunCreateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildCreateArgsV1(runtime)
d := common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: create-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "create-doc", "arguments": args}}).
Set("mcp_tool", "create-doc").Set("args", args)
if runtime.IsBot() {
d.Desc("After create-doc succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document.")
}
return d
}
func executeCreateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+create")
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildCreateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "create-doc", args)
if err != nil {
return err
}
augmentCreateResultV1(runtime, result)
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildCreateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
md := runtime.Str("markdown")
args := map[string]interface{}{
"markdown": md,
}
if v := runtime.Str("title"); v != "" {
args["title"] = v
}
if v := runtime.Str("folder-token"); v != "" {
args["folder_token"] = v
}
if v := runtime.Str("wiki-node"); v != "" {
args["wiki_node"] = v
}
if v := runtime.Str("wiki-space"); v != "" {
args["wiki_space"] = v
}
return args
}
type docsPermissionTarget struct {
Token string
Type string
}
func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]interface{}) {
target := selectPermissionTarget(result)
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
result["permission_grant"] = grant
}
fallbackDocURLV1(runtime, result)
}
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
// response did not include one but did include a doc_id. This protects against
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
// drops structured fields.
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
return
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID == "" {
return
}
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
result["doc_url"] = u
}
}
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
if ref, ok := parsePermissionTargetFromURL(common.GetString(result, "doc_url")); ok {
return ref
}
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
if docID != "" {
return docsPermissionTarget{Token: docID, Type: "docx"}
}
return docsPermissionTarget{}
}
func parsePermissionTargetFromURL(docURL string) (docsPermissionTarget, bool) {
if strings.TrimSpace(docURL) == "" {
return docsPermissionTarget{}, false
}
ref, err := parseDocumentRef(docURL)
if err != nil {
return docsPermissionTarget{}, false
}
switch ref.Kind {
case "wiki":
return docsPermissionTarget{Token: ref.Token, Type: "wiki"}, true
case "doc", "docx":
return docsPermissionTarget{Token: ref.Token, Type: ref.Kind}, true
default:
return docsPermissionTarget{}, false
}
}
// normalizeWhiteboardResult normalizes board_tokens in the MCP response when
// whiteboard creation markdown is detected.
func normalizeWhiteboardResult(result map[string]interface{}, markdown string) {
if !isWhiteboardCreateMarkdown(markdown) {
return
}
result["board_tokens"] = normalizeBoardTokens(result["board_tokens"])
}
func isWhiteboardCreateMarkdown(markdown string) bool {
lower := strings.ToLower(markdown)
if strings.Contains(lower, "```mermaid") || strings.Contains(lower, "```plantuml") {
return true
}
return strings.Contains(lower, "<whiteboard") &&
(strings.Contains(lower, `type="blank"`) || strings.Contains(lower, `type='blank'`))
}
func normalizeBoardTokens(raw interface{}) []string {
switch v := raw.(type) {
case nil:
return []string{}
case []string:
return v
case []interface{}:
tokens := make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok && s != "" {
tokens = append(tokens, s)
}
}
return tokens
case string:
if v == "" {
return []string{}
}
return []string{v}
default:
return []string{}
}
}
// ── Shared helpers ──
// concatFlags combines multiple flag slices into one.
func concatFlags(slices ...[]common.Flag) []common.Flag {
var out []common.Flag
@@ -258,15 +48,3 @@ func concatFlags(slices ...[]common.Flag) []common.Flag {
}
return out
}
// buildFlagVersionMap creates a flag name → version mapping from v1 and v2 flag lists.
func buildFlagVersionMap(v1, v2 []common.Flag) map[string]string {
m := make(map[string]string, len(v1)+len(v2))
for _, f := range v1 {
m[f.Name] = "v1"
}
for _, f := range v2 {
m[f.Name] = "v2"
}
return m
}

View File

@@ -48,7 +48,6 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
@@ -249,148 +248,63 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
}
}
// ── V1 (MCP) tests ──
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
}
func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://example.feishu.cn/wiki/wikcn_new_node",
"message": "文档创建成功",
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/wikcn_new_node/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 内容",
"--wiki-space", "my_library",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["perm_type"] != "container" {
t.Fatalf("permission request perm_type = %#v, want %q", body["perm_type"], "container")
}
}
func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"message": "文档创建成功",
// "doc_url" deliberately omitted to exercise the fallback.
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--content", "<title>项目计划</title>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
doc, _ := data["document"].(map[string]interface{})
if got, want := doc["document_id"], "doxcn_new_doc"; got != want {
t.Fatalf("document.document_id = %#v, want %q", got, want)
}
}
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
"message": "文档创建成功",
})
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
if err == nil {
t.Fatal("expected legacy v1 flags to be rejected")
}
data := decodeDocsCreateEnvelope(t, stdout)
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
for _, want := range []string{
"docs +create is v2-only",
"the old v1 interface has been shut down",
"legacy v1 flag(s) --title, --markdown are no longer supported",
"--title -> put the title in --content",
"--markdown -> use --content with --doc-format markdown",
"lark-cli skills read lark-doc references/lark-doc-create.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"follow the latest format rules",
"MUST NOT grep/open local SKILL.md files",
"lark-cli docs +create --help",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
}
@@ -421,24 +335,6 @@ func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface
})
}
func registerDocsCreateMCPStub(reg *httpmock.Registry, result map[string]interface{}) {
payload, _ := json.Marshal(result)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/mcp",
Body: map[string]interface{}{
"result": map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": string(payload),
},
},
},
},
})
}
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()

View File

@@ -13,14 +13,17 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{Name: "content", Desc: "document content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder or wiki-node token", Hidden: true},
{Name: "parent-position", Desc: "parent position (e.g. my_library)", Hidden: true},
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"},
{Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"},
}
}
func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
if err := validateDocsV2Only(runtime, "+create", docsCreateLegacyFlags()); err != nil {
return err
}
if runtime.Str("content") == "" {
return common.FlagErrorf("--content is required")
}

View File

@@ -5,40 +5,13 @@ package doc
import (
"context"
"fmt"
"io"
"strconv"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
// v1FetchFlags returns hidden parse-only compatibility flags for old v1 commands.
func v1FetchFlags() []common.Flag {
return []common.Flag{
{Name: "offset", Desc: "pagination offset", Hidden: true},
{Name: "limit", Desc: "pagination limit", Hidden: true},
}
}
var docsFetchFlagVersions = buildFlagVersionMap(v1FetchFlags(), v2FetchFlags())
// useV2Fetch returns true when the v2 (OpenAPI) fetch path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by the
// presence of any v2-only flag on the command line — we check pflag.Changed
// rather than the value so that explicitly typing `--detail simple` (equal
// to the default) still routes to v2.
func useV2Fetch(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
for _, name := range []string{"detail", "doc-format", "scope", "revision-id", "start-block-id", "end-block-id", "keyword", "context-before", "context-after", "max-depth"} {
if runtime.Changed(name) {
return true
}
}
return false
return docsLegacyFlagDefinitions(docsFetchLegacyFlags())
}
var DocsFetch = common.Shortcut{
@@ -49,88 +22,22 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Tips: docsVersionSelectionTips,
PostMount: installDocsShortcutHelp("+fetch"),
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
docsAPIVersionCompatFlag(),
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1FetchFlags(),
v2FetchFlags(),
v1FetchFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Fetch(runtime) {
return validateFetchV2(ctx, runtime)
}
return nil
return validateFetchV2(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Fetch(runtime) {
return dryRunFetchV2(ctx, runtime)
}
return dryRunFetchV1(ctx, runtime)
return dryRunFetchV2(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Fetch(runtime) {
return executeFetchV2(ctx, runtime)
}
return executeFetchV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
return executeFetchV2(ctx, runtime)
},
}
// ── V1 (MCP) implementation ──
func dryRunFetchV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildFetchArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: fetch-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "fetch-doc", "arguments": args}}).
Set("mcp_tool", "fetch-doc").Set("args", args)
}
func executeFetchV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+fetch")
args := buildFetchArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
return err
}
if md, ok := result["markdown"].(string); ok {
result["markdown"] = fixExportedMarkdown(md)
}
runtime.OutFormat(result, nil, func(w io.Writer) {
if title, ok := result["title"].(string); ok && title != "" {
fmt.Fprintf(w, "# %s\n\n", title)
}
if md, ok := result["markdown"].(string); ok {
fmt.Fprintln(w, md)
}
if hasMore, ok := result["has_more"].(bool); ok && hasMore {
fmt.Fprintln(w, "\n--- more content available, use --offset and --limit to paginate ---")
}
})
return nil
}
func buildFetchArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"skip_task_detail": true,
}
if v := runtime.Str("offset"); v != "" {
n, _ := strconv.Atoi(v)
args["offset"] = n
}
if v := runtime.Str("limit"); v != "" {
n, _ := strconv.Atoi(v)
args["limit"] = n
}
return args
}

View File

@@ -16,16 +16,16 @@ import (
// v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path.
func v2FetchFlags() []common.Flag {
return []common.Flag{
{Name: "doc-format", Desc: "content format", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "export detail level: simple (read-only) | with-ids (block IDs for cross-referencing) | full (all attrs for editing)", Hidden: true, Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "scope", Desc: "partial read scope: outline | range | keyword | section (omit to read whole doc)", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section mode: start (anchor) block id"},
{Name: "end-block-id", Desc: "range mode: end block id; \"-1\" = to end of document"},
{Name: "keyword", Desc: "keyword mode: substring + regex match (case-insensitive); use '|' for OR branches, e.g. 'foo|bar' or 'bug|缺陷'"},
{Name: "context-before", Desc: "range/keyword/section mode: sibling blocks before match", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section mode: sibling blocks after match", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline: heading level cap; range/keyword/section: block subtree depth (-1 = unlimited)", Type: "int", Default: "-1"},
{Name: "doc-format", Desc: "output content format; xml keeps DocxXML structure and optional block ids, markdown is plain export", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "detail", Desc: "detail level; simple for reading, with-ids for block references, full for styles and edit metadata", Default: "simple", Enum: []string{"simple", "with-ids", "full"}},
{Name: "revision-id", Desc: "document revision id; -1 means latest", Type: "int", Default: "-1"},
{Name: "scope", Desc: "read scope; full reads whole doc, outline lists headings, section expands from heading anchor, range uses block ids, keyword searches text", Default: "full", Enum: []string{"full", "outline", "range", "keyword", "section"}},
{Name: "start-block-id", Desc: "range/section anchor block id; required for section and optional start for range"},
{Name: "end-block-id", Desc: "range end block id; -1 means through document end"},
{Name: "keyword", Desc: "keyword scope query; supports case-insensitive substring/regex fallback and '|' OR branches, e.g. foo|bar or bug|缺陷"},
{Name: "context-before", Desc: "range/keyword/section context: sibling blocks before selected top-level blocks", Type: "int", Default: "0"},
{Name: "context-after", Desc: "range/keyword/section context: sibling blocks after selected top-level blocks", Type: "int", Default: "0"},
{Name: "max-depth", Desc: "outline heading level cap; other scopes subtree depth where -1 is unlimited and 0 is block only", Type: "int", Default: "-1"},
}
}
@@ -33,6 +33,9 @@ func v2FetchFlags() []common.Flag {
// --dry-run so that invalid input fails with a structured exit code (2) and
// JSON envelope instead of slipping through dry-run as a "success".
func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error {
if err := validateDocsV2Only(runtime, "+fetch", docsFetchLegacyFlags()); err != nil {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return common.FlagErrorf("invalid --doc: %v", err)
}

View File

@@ -5,6 +5,7 @@ package doc
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
@@ -58,6 +59,82 @@ func TestBuildFetchBodyOmitsEmptyScene(t *testing.T) {
}
}
func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", nil)
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
if len(dry.API) != 1 {
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
}
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want {
t.Fatalf("dry-run URL = %q, want %q", got, want)
}
if got, want := dry.API[0].Body["format"], "xml"; got != want {
t.Fatalf("dry-run format = %#v, want %q", got, want)
}
}
func TestDocsFetchAPIVersionV1StillUsesV2Endpoint(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "v1", nil)
if err := validateFetchV2(context.Background(), runtime); err != nil {
t.Fatalf("validateFetchV2() error = %v", err)
}
dry := decodeDocDryRun(t, DocsFetch.DryRun(context.Background(), runtime))
if len(dry.API) != 1 {
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
}
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnFetchDryRun/fetch"; got != want {
t.Fatalf("dry-run URL = %q, want %q", got, want)
}
}
func TestDocsFetchRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
setFlags map[string]string
want []string
}{
{
name: "legacy offset",
setFlags: map[string]string{"offset": "10"},
want: []string{
"docs +fetch is v2-only",
"the old v1 interface has been shut down",
"legacy v1 flag(s) --offset are no longer supported",
"--offset -> use --scope outline/range/keyword/section",
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
"MUST NOT grep/open local SKILL.md files",
"lark-cli docs +fetch --help",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newFetchShortcutTestRuntime(t, "", tt.setFlags)
err := validateFetchV2(context.Background(), runtime)
if err == nil {
t.Fatal("expected v2-only validation error")
}
for _, want := range tt.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
})
}
}
func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("doc-format", "xml", "")
@@ -73,6 +150,37 @@ func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil)
}
func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "+fetch"}
cmd.Flags().String("api-version", "", "")
cmd.Flags().String("doc", "doxcnFetchDryRun", "")
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("detail", "simple", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("scope", "full", "")
cmd.Flags().String("start-block-id", "", "")
cmd.Flags().String("end-block-id", "", "")
cmd.Flags().String("keyword", "", "")
cmd.Flags().Int("context-before", 0, "")
cmd.Flags().Int("context-after", 0, "")
cmd.Flags().Int("max-depth", -1, "")
cmd.Flags().String("offset", "", "")
cmd.Flags().String("limit", "", "")
if apiVersion != "" {
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
t.Fatalf("set api-version: %v", err)
}
}
for name, value := range setFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
return common.TestNewRuntimeContext(cmd, nil)
}
func newCreateBodyTestRuntime(ctx context.Context) *common.RuntimeContext {
cmd := &cobra.Command{Use: "+create"}
cmd.Flags().String("doc-format", "xml", "")

View File

@@ -5,57 +5,13 @@ package doc
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
var validModesV1 = map[string]bool{
"append": true,
"overwrite": true,
"replace_range": true,
"replace_all": true,
"insert_before": true,
"insert_after": true,
"delete_range": true,
}
var needsSelectionV1 = map[string]bool{
"replace_range": true,
"replace_all": true,
"insert_before": true,
"insert_after": true,
"delete_range": true,
}
// v1UpdateFlags returns the flag definitions for the v1 (MCP) update path.
// v1UpdateFlags returns hidden parse-only compatibility flags for old v1 commands.
func v1UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "mode", Desc: "update mode: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", Hidden: true},
{Name: "markdown", Desc: "new content (Lark-flavored Markdown; create blank whiteboards with <whiteboard type=\"blank\"></whiteboard>, repeat to create multiple boards)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "selection-with-ellipsis", Desc: "content locator (e.g. 'start...end')", Hidden: true},
{Name: "selection-by-title", Desc: "title locator (e.g. '## Section')", Hidden: true},
{Name: "new-title", Desc: "also update document title", Hidden: true},
}
}
var docsUpdateFlagVersions = buildFlagVersionMap(v1UpdateFlags(), v2UpdateFlags())
// useV2Update returns true when the v2 (OpenAPI) update path should be used.
// Explicit --api-version v2 takes priority; otherwise auto-detect by v2-only flags.
func useV2Update(runtime *common.RuntimeContext) bool {
if runtime.Str("api-version") == "v2" {
return true
}
return runtime.Str("command") != "" ||
runtime.Str("content") != "" ||
runtime.Str("pattern") != "" ||
runtime.Str("block-id") != "" ||
runtime.Str("src-block-ids") != ""
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
}
var DocsUpdate = common.Shortcut{
@@ -65,225 +21,22 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
Tips: docsVersionSelectionTips,
PostMount: installDocsShortcutHelp("+update"),
Flags: concatFlags(
[]common.Flag{
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
docsAPIVersionCompatFlag(),
{Name: "doc", Desc: "document URL or token", Required: true},
},
v1UpdateFlags(),
v2UpdateFlags(),
v1UpdateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Update(runtime) {
return validateUpdateV2(ctx, runtime)
}
return validateUpdateV1(ctx, runtime)
return validateUpdateV2(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if useV2Update(runtime) {
return dryRunUpdateV2(ctx, runtime)
}
return dryRunUpdateV1(ctx, runtime)
return dryRunUpdateV2(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if useV2Update(runtime) {
return executeUpdateV2(ctx, runtime)
}
return executeUpdateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
return executeUpdateV2(ctx, runtime)
},
}
// ── V1 (MCP) implementation ──
func validateUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
mode := runtime.Str("mode")
if mode == "" {
return common.FlagErrorf("--mode is required")
}
if !validModesV1[mode] {
return common.FlagErrorf("invalid --mode %q, valid: append | overwrite | replace_range | replace_all | insert_before | insert_after | delete_range", mode)
}
if mode != "delete_range" && runtime.Str("markdown") == "" {
return common.FlagErrorf("--%s mode requires --markdown", mode)
}
selEllipsis := runtime.Str("selection-with-ellipsis")
selTitle := runtime.Str("selection-by-title")
if selEllipsis != "" && selTitle != "" {
return common.FlagErrorf("--selection-with-ellipsis and --selection-by-title are mutually exclusive")
}
if needsSelectionV1[mode] && selEllipsis == "" && selTitle == "" {
return common.FlagErrorf(selectionRequiredMessageV1(mode))
}
if err := validateSelectionByTitleV1(selTitle); err != nil {
return err
}
return nil
}
func selectionRequiredMessageV1(mode string) string {
msg := fmt.Sprintf("--%s mode requires --selection-with-ellipsis or --selection-by-title", mode)
if mode == "replace_all" {
msg += ". If you intended to replace the entire document body, use --mode overwrite instead."
}
return msg
}
func validateSelectionByTitleV1(title string) error {
if title == "" {
return nil
}
trimmed := strings.TrimSpace(title)
if strings.Contains(trimmed, "\n") || strings.Contains(trimmed, "\r") {
return common.FlagErrorf("--selection-by-title must be a single heading line (for example: '## Section')")
}
if strings.HasPrefix(trimmed, "#") {
return nil
}
return common.FlagErrorf("--selection-by-title must include markdown heading prefix '#'. Example: --selection-by-title '## Section'")
}
func dryRunUpdateV1(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
args := buildUpdateArgsV1(runtime)
return common.NewDryRunAPI().
POST(common.MCPEndpoint(runtime.Config.Brand)).
Desc("MCP tool: update-doc").
Body(map[string]interface{}{"method": "tools/call", "params": map[string]interface{}{"name": "update-doc", "arguments": args}}).
Set("mcp_tool", "update-doc").Set("args", args)
}
func executeUpdateV1(_ context.Context, runtime *common.RuntimeContext) error {
warnDeprecatedV1(runtime, "+update")
// Static semantic checks run before the MCP call so users see
// warnings even if the subsequent request fails. They never block
// execution — the update still proceeds.
for _, w := range docsUpdateWarnings(runtime.Str("mode"), runtime.Str("markdown")) {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
// Overwrite replaces the entire document, silently discarding any
// whiteboard or file-attachment blocks that cannot be re-created from
// Markdown. Pre-fetch the current content and warn when such blocks
// are present so the caller can take a backup before proceeding.
if runtime.Str("mode") == "overwrite" {
if w := warnOverwriteResourceBlocks(runtime); w != "" {
fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", w)
}
}
// Surface callout type= hint so users know to switch to background-color/
// border-color when they want a colored callout. Non-blocking, advisory.
if md := runtime.Str("markdown"); md != "" {
WarnCalloutType(md, runtime.IO().ErrOut)
}
args := buildUpdateArgsV1(runtime)
result, err := common.CallMCPTool(runtime, "update-doc", args)
if err != nil {
return err
}
normalizeWhiteboardResult(result, runtime.Str("markdown"))
runtime.Out(result, nil)
return nil
}
func buildUpdateArgsV1(runtime *common.RuntimeContext) map[string]interface{} {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
"mode": runtime.Str("mode"),
}
if v := runtime.Str("markdown"); v != "" {
args["markdown"] = v
}
if v := runtime.Str("selection-with-ellipsis"); v != "" {
args["selection_with_ellipsis"] = v
}
if v := runtime.Str("selection-by-title"); v != "" {
args["selection_by_title"] = v
}
if v := runtime.Str("new-title"); v != "" {
args["new_title"] = v
}
return args
}
// resourceBlockRe matches the opening of a <whiteboard …> or <file …> tag
// (followed by whitespace, > or /) to avoid false positives on tag names like
// <file-view> or prose that merely mentions the word "whiteboard".
var resourceBlockRe = regexp.MustCompile(`<(whiteboard|file)[\s/>]`)
// warnOverwriteResourceBlocks pre-fetches the current document and returns a
// non-empty warning string when the document contains whiteboard or file
// attachment blocks that would be permanently deleted by an overwrite. Returns
// an empty string (no warning) when the document is clean or the fetch fails
// (we never block the overwrite on a best-effort check).
//
// This function is not unit-tested because it depends on an external MCP call
// (fetch-doc). The pure detection logic lives in checkOverwriteResourceBlocks,
// which has full table-driven coverage.
//
// Performance: this adds one extra fetch-doc round-trip to every --mode overwrite
// call, even when the document has no resource blocks. The cost is intentional:
// the guard is best-effort and silent on failure, so the latency is bounded and
// the trade-off is acceptable to avoid silent data loss.
func warnOverwriteResourceBlocks(runtime *common.RuntimeContext) string {
args := map[string]interface{}{
"doc_id": runtime.Str("doc"),
// skip_task_detail reduces response payload by omitting per-block task
// metadata, making the pre-fetch faster and cheaper.
"skip_task_detail": true,
}
result, err := common.CallMCPTool(runtime, "fetch-doc", args)
if err != nil {
// Fetch failed — silently skip the guard rather than blocking overwrite.
return ""
}
md, _ := result["markdown"].(string)
return checkOverwriteResourceBlocks(md)
}
// checkOverwriteResourceBlocks scans Markdown for resource block tags that
// cannot survive an overwrite: <whiteboard …> and <file …>. Returns a
// warning string listing the counts if any are found, empty string otherwise.
func checkOverwriteResourceBlocks(markdown string) string {
matches := resourceBlockRe.FindAllStringSubmatch(markdown, -1)
whiteboards, files := 0, 0
for _, m := range matches {
switch m[1] {
case "whiteboard":
whiteboards++
case "file":
files++
}
}
var found []string
if whiteboards == 1 {
found = append(found, "1 whiteboard block")
} else if whiteboards > 1 {
found = append(found, fmt.Sprintf("%d whiteboard blocks", whiteboards))
}
if files == 1 {
found = append(found, "1 file attachment block")
} else if files > 1 {
found = append(found, fmt.Sprintf("%d file attachment blocks", files))
}
if len(found) == 0 {
return ""
}
return fmt.Sprintf(
"the document contains %s that cannot be reconstructed from Markdown; "+
"overwrite will permanently delete them. "+
"Consider fetching a backup with `docs +fetch` before overwriting.",
strings.Join(found, " and "),
)
}

View File

@@ -1,281 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"regexp"
"strings"
)
// docsUpdateWarnings returns a list of human-readable warnings for a
// `docs +update` invocation based on static analysis of the mode and
// Markdown payload. The warnings describe CLI/MCP contract edges that
// commonly surprise users; the update is still executed — callers
// decide whether to stop at a warning.
//
// Both checks ignore fenced code blocks (```…``` and ~~~…~~~, with up
// to 3 leading spaces per CommonMark §4.5), inline code spans, and
// backslash-escaped emphasis markers so that literal Markdown content
// embedded in code samples or escaped prose does not produce false
// positives.
//
// Warnings emitted (current):
//
// 1. replace_* modes do not split blocks. A Markdown payload containing
// a blank line (\n\n) in prose implies the caller expects multiple
// paragraphs, but replace_range / replace_all only swap in-block
// text. The resulting block will contain the blank line as literal
// text and appear as a single paragraph in the UI.
//
// 2. Lark does not round-trip bold+italic. Six shapes are detected:
// ***text*** ___text___
// **_text_** __*text*__
// _**text**_ *__text__*
// Lark stores only one of the two emphases (usually italic), silently
// dropping the other. The user wanted both; they will get one.
func docsUpdateWarnings(mode, markdown string) []string {
var warnings []string
if w := checkDocsUpdateReplaceMultilineMarkdown(mode, markdown); w != "" {
warnings = append(warnings, w)
}
if w := checkDocsUpdateBoldItalic(markdown); w != "" {
warnings = append(warnings, w)
}
return warnings
}
// checkDocsUpdateReplaceMultilineMarkdown flags markdown that contains a
// blank-line paragraph break outside fenced code blocks under a replace_*
// mode. Blank lines inside code fences are literal content and don't
// imply paragraph semantics, so they are deliberately ignored.
func checkDocsUpdateReplaceMultilineMarkdown(mode, markdown string) string {
if mode != "replace_range" && mode != "replace_all" {
return ""
}
// A CR/LF-robust check: both "\n\n" and "\r\n\r\n" count as paragraph
// separators. We normalize line endings once before detection.
normalized := strings.ReplaceAll(markdown, "\r\n", "\n")
if !proseHasBlankLine(normalized) {
return ""
}
return "--mode=" + mode + " does not split a block into multiple paragraphs; " +
"the blank line in --markdown will render as literal text. " +
"For multiple paragraphs, use --mode=delete_range followed by --mode=insert_before."
}
// combinedEmphasisPatterns holds the six documented combined-emphasis shapes
// that Lark downgrades to a single emphasis. Each entry pairs a regex with a
// short shape label for the warning message. The two forms per shape (with
// and without `[^…]*?`) are there because the lazy quantifier needs at least
// one non-delimiter character to match; single-rune payloads (e.g. `***X***`)
// take the second alternation.
var combinedEmphasisPatterns = []struct {
shape string
re *regexp.Regexp
}{
// Bold+italic with a single delimiter char.
{"***text***", regexp.MustCompile(`\*\*\*\S[^*]*?\S\*\*\*|\*\*\*\S\*\*\*`)},
{"___text___", regexp.MustCompile(`___\S[^_]*?\S___|___\S___`)},
// Bold wrapping italic (asterisk outside).
{"**_text_**", regexp.MustCompile(`\*\*_\S[^_*]*?\S_\*\*|\*\*_\S_\*\*`)},
{"__*text*__", regexp.MustCompile(`__\*\S[^_*]*?\S\*__|__\*\S\*__`)},
// Italic wrapping bold (asterisk inside).
{"_**text**_", regexp.MustCompile(`_\*\*\S[^_*]*?\S\*\*_|_\*\*\S\*\*_`)},
{"*__text__*", regexp.MustCompile(`\*__\S[^_*]*?\S__\*|\*__\S__\*`)},
}
// checkDocsUpdateBoldItalic flags Markdown emphases that attempt to
// combine bold and italic in a way Lark cannot represent. Fenced code
// blocks, inline code spans, and backslash-escaped emphasis markers are
// stripped first so that literal markdown examples ("here is a
// `***keyword***` to flag") do not trigger the warning.
func checkDocsUpdateBoldItalic(markdown string) string {
if markdown == "" {
return ""
}
sanitized := stripEscapedEmphasisMarkers(stripMarkdownCodeRegions(markdown))
for _, p := range combinedEmphasisPatterns {
if p.re.MatchString(sanitized) {
return "Lark does not support combined bold+italic markers " +
"(e.g. ***text***, ___text___, **_text_**, _**text**_, __*text*__, *__text__*); " +
"the emphasis will be downgraded to either bold or italic. " +
"Split into two separate emphases or drop one of them."
}
}
return ""
}
// proseHasBlankLine reports whether markdown contains a blank line outside
// of fenced code blocks. Blank lines inside ```...``` or ~~~...~~~ fences
// are code content, not paragraph separators, and must not trip the
// "replace_* cannot split paragraphs" warning.
//
// A blank line counts only when it sits between two non-blank boundaries
// (other prose, or a fence open/close). A trailing empty line at EOF is
// not treated as "\n\n".
func proseHasBlankLine(markdown string) bool {
lines := strings.Split(markdown, "\n")
inFence := false
var fenceMarker string
for i, line := range lines {
if inFence {
if isCodeFenceClose(line, fenceMarker) {
inFence = false
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
inFence = true
fenceMarker = marker
continue
}
if strings.TrimSpace(line) == "" && i > 0 && i+1 < len(lines) {
return true
}
}
return false
}
// stripMarkdownCodeRegions returns markdown with fenced code blocks blanked
// out and inline code spans replaced by whitespace of equivalent length.
// Byte offsets outside the masked regions are preserved, so follow-on
// regex matches still point at real prose positions.
func stripMarkdownCodeRegions(markdown string) string {
lines := strings.Split(markdown, "\n")
inFence := false
var fenceMarker string
for i, line := range lines {
if inFence {
if isCodeFenceClose(line, fenceMarker) {
inFence = false
fenceMarker = ""
}
lines[i] = ""
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
inFence = true
fenceMarker = marker
lines[i] = ""
continue
}
lines[i] = maskInlineCodeSpans(line)
}
return strings.Join(lines, "\n")
}
// maskInlineCodeSpans replaces the byte ranges of any inline code spans in
// line with space characters of equal length. Uses scanInlineCodeSpans from
// markdown_fix.go, which implements the CommonMark §6.1 matching-backtick-run
// rule (so “ `a`b` “ is a single span).
func maskInlineCodeSpans(line string) string {
spans := scanInlineCodeSpans(line)
if len(spans) == 0 {
return line
}
var sb strings.Builder
pos := 0
for _, loc := range spans {
sb.WriteString(line[pos:loc[0]])
sb.WriteString(strings.Repeat(" ", loc[1]-loc[0]))
pos = loc[1]
}
sb.WriteString(line[pos:])
return sb.String()
}
// stripEscapedEmphasisMarkers removes backslash-escaped '*' and '_' so the
// bold/italic regexes don't treat literal sequences like `\***text***` as
// real combined emphasis. CommonMark renders "\*" as a literal "*" with no
// emphasis semantics; dropping the escape + its target from the detection
// input keeps the heuristic aligned with what the renderer actually does.
//
// Known limitation: a doubled backslash escape ("\\" followed by a real
// emphasis marker, e.g. `\\***text***`) renders as a literal backslash
// followed by genuine combined emphasis, but this strip is not a proper
// parser and will instead consume the second backslash as the opener for
// another escape. That hides the real emphasis from the check, producing
// a false negative. Practical impact is small (this shape is rare in the
// kind of AI-Agent prompts we target) and the alternative — a full
// CommonMark escape parser — is not worth the code surface here.
func stripEscapedEmphasisMarkers(s string) string {
s = strings.ReplaceAll(s, `\*`, "")
s = strings.ReplaceAll(s, `\_`, "")
return s
}
// codeFenceOpenMarker returns the fence marker (e.g. "```" or "~~~~") if
// line opens a fenced code block, otherwise "". Applies CommonMark §4.5
// rules: up to 3 leading spaces are tolerated; 4+ leading spaces (or any
// leading tab, which expands to 4 columns) make the line an indented code
// block rather than a fence.
func codeFenceOpenMarker(line string) string {
body, ok := fenceIndentOK(line)
if !ok {
return ""
}
switch {
case strings.HasPrefix(body, "```"):
return leadingRun(body, '`')
case strings.HasPrefix(body, "~~~"):
return leadingRun(body, '~')
}
return ""
}
// isCodeFenceClose reports whether line closes a fence opened with marker.
// Per CommonMark §4.5 the closer must use the same fence character, be at
// least as long as the opener, sit within 0..3 leading spaces, and carry
// no info-string text.
func isCodeFenceClose(line, marker string) bool {
if marker == "" {
return false
}
body, ok := fenceIndentOK(line)
if !ok {
return false
}
fenceChar := marker[0]
run := leadingRun(body, fenceChar)
if len(run) < len(marker) {
return false
}
return strings.TrimSpace(body[len(run):]) == ""
}
// fenceIndentOK returns (bodyWithoutLeadingSpaces, true) when line has
// 0..3 leading spaces and no leading tab — i.e. the indentation is
// permissible for a CommonMark fence. Returns ("", false) otherwise
// (4+ leading spaces or any tab), meaning the line must be treated as
// indented code block content rather than a fence boundary.
func fenceIndentOK(line string) (string, bool) {
for i := 0; i < len(line) && i < 4; i++ {
switch line[i] {
case ' ':
continue
case '\t':
return "", false
default:
return line[i:], true
}
}
// Reached index 4 without hitting a non-space character: too indented.
if len(line) >= 4 {
return "", false
}
// Line shorter than 4 chars and all spaces — still valid (empty content).
return "", true
}
// leadingRun returns the longest prefix of s made up of the byte c.
func leadingRun(s string, c byte) string {
i := 0
for i < len(s) && s[i] == c {
i++
}
return s[:i]
}

View File

@@ -1,375 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
)
func TestCheckDocsUpdateReplaceMultilineMarkdown(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mode string
markdown string
wantHint bool
}{
{
name: "replace_range with blank line emits hint",
mode: "replace_range",
markdown: "new paragraph\n\nsecond paragraph",
wantHint: true,
},
{
name: "replace_all with blank line emits hint",
mode: "replace_all",
markdown: "first\n\nsecond",
wantHint: true,
},
{
name: "replace_range single paragraph is fine",
mode: "replace_range",
markdown: "just a single paragraph of text",
wantHint: false,
},
{
name: "single newline is not a paragraph break",
mode: "replace_range",
markdown: "line one\nline two",
wantHint: false,
},
{
name: "crlf paragraph break is also detected",
mode: "replace_range",
markdown: "first\r\n\r\nsecond",
wantHint: true,
},
{
name: "other modes are not flagged",
mode: "insert_before",
markdown: "first\n\nsecond",
wantHint: false,
},
{
name: "append mode is not flagged",
mode: "append",
markdown: "first\n\nsecond",
wantHint: false,
},
{
name: "empty markdown is fine",
mode: "replace_range",
markdown: "",
wantHint: false,
},
{
// The check must ignore blank lines inside fenced code; otherwise
// a user replacing one block with a legitimate code sample that
// contains blank lines would see a spurious warning.
name: "blank line inside backtick fenced code is not flagged",
mode: "replace_range",
markdown: "```\nline1\n\nline2\n```",
wantHint: false,
},
{
name: "blank line inside tilde fenced code is not flagged",
mode: "replace_range",
markdown: "~~~\ncode line one\n\ncode line two\n~~~",
wantHint: false,
},
{
// Mixed prose + fenced code: any blank line in prose still wins,
// even if the fenced content also contains blanks.
name: "blank line in prose outside fence still flags even when fence has blanks",
mode: "replace_range",
markdown: "first paragraph\n\nsecond paragraph\n\n```\ncode\n\nmore\n```",
wantHint: true,
},
{
// Fenced code with no blank lines inside must not trip on the
// fence markers themselves.
name: "fenced code with no blank lines does not flag",
mode: "replace_range",
markdown: "prose before\n```go\nfmt.Println(\"hi\")\n```\nprose after",
wantHint: false,
},
{
// CommonMark §4.5: the closing fence must be ≥ opening fence length.
// A 4-backtick close for a 3-backtick open is a legitimate way to
// embed triple-backticks in a code sample; the check must see the
// fence as properly closed and not treat the rest of the document
// as still-inside-fence.
name: "longer close marker closes fence correctly",
mode: "replace_range",
markdown: "```\nsome code\n````\n\nprose paragraph after",
wantHint: true, // the blank line AFTER the fence is real prose
},
{
name: "longer close marker still hides blank line inside fence",
mode: "replace_range",
markdown: "```\nbefore\n\nafter\n````",
wantHint: false,
},
{
// 4+ leading spaces make the line an indented code block, not a
// fence open. The "fence"-looking line is code content; the
// surrounding blank must still be detected.
name: "four-space indented fence-like line is not a fence open",
mode: "replace_range",
markdown: "first paragraph\n\n ```\n code\n ```",
wantHint: true,
},
{
// A tab in the leading whitespace is always ≥4 columns and thus
// forces indented-code-block semantics.
name: "tab-indented fence-like line is not a fence open",
mode: "replace_range",
markdown: "first paragraph\n\n\t```\n\tcode\n\t```",
wantHint: true,
},
{
// 3 leading spaces is still within the fence-tolerance window.
name: "three-space indented fence is still a fence",
mode: "replace_range",
markdown: " ```\ncode\n\nmore\n ```",
wantHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkDocsUpdateReplaceMultilineMarkdown(tt.mode, tt.markdown)
hasHint := got != ""
if hasHint != tt.wantHint {
t.Fatalf("checkDocsUpdateReplaceMultilineMarkdown(%q, %q) = %q, wantHint=%v",
tt.mode, tt.markdown, got, tt.wantHint)
}
if tt.wantHint && (!strings.Contains(got, "delete_range") || !strings.Contains(got, "insert_before")) {
t.Errorf("hint should suggest delete_range/insert_before remediation, got: %s", got)
}
})
}
}
func TestCheckDocsUpdateBoldItalic(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
wantHint bool
}{
{
name: "triple asterisks flagged",
input: "a ***key insight*** here",
wantHint: true,
},
{
name: "triple asterisks single char flagged",
input: "a ***X*** here",
wantHint: true,
},
{
name: "bold wrapping underscore italic flagged",
input: "note: **_important_** detail",
wantHint: true,
},
{
name: "underscore wrapping double asterisk flagged",
input: "note: _**important**_ detail",
wantHint: true,
},
{
name: "plain bold is fine",
input: "this is **bold** text",
wantHint: false,
},
{
name: "plain italic is fine",
input: "this is *italic* or _italic_ text",
wantHint: false,
},
{
name: "horizontal rule is not flagged",
input: "paragraph\n\n---\n\nnext",
wantHint: false,
},
{
name: "bold followed by italic with space is not flagged",
input: "**bold** and *italic*",
wantHint: false,
},
{
name: "empty input is fine",
input: "",
wantHint: false,
},
{
// The emphasis check must not fire on literal Markdown samples
// inside a fenced code block — the canonical use case is docs
// authors pasting tutorials that demonstrate these exact patterns.
name: "triple asterisks inside backtick fenced code is not flagged",
input: "example:\n```\nthe shape ***keyword*** downgrades\n```",
wantHint: false,
},
{
name: "underscore-bold inside fenced code is not flagged",
input: "example:\n```markdown\nuse **_strong italic_** carefully\n```",
wantHint: false,
},
{
name: "bold-underscore inside fenced code is not flagged",
input: "example:\n~~~\n_**outside-underscore**_ is a bad shape\n~~~",
wantHint: false,
},
{
name: "triple asterisks inside inline code span is not flagged",
input: "the literal `***text***` marker is just a sample",
wantHint: false,
},
{
name: "underscore-bold inside inline code is not flagged",
input: "the shape `**_italic_**` would downgrade, but only if it were real",
wantHint: false,
},
{
name: "escaped triple asterisks rendered as literal text is not flagged",
input: `the literal \***text*** with escaped opener`,
wantHint: false,
},
{
name: "escaped bold inside underscore-italic is not flagged",
input: `shape \*\*_text_\*\* is literal, not emphasis`,
wantHint: false,
},
{
// Real emphasis outside the code span must still be detected —
// the strip step must not over-sanitize.
name: "real triple asterisks outside inline code still flags",
input: "real ***strong*** and literal `***keyword***` — the first one counts",
wantHint: true,
},
{
name: "real triple asterisks outside fenced code still flags",
input: "real ***strong***\n\n```\nliteral ***keyword*** in code\n```",
wantHint: true,
},
// --- Triple-underscore combined emphasis: ___text___ ---
{
name: "triple underscores flagged",
input: "a ___key insight___ here",
wantHint: true,
},
{
name: "triple underscores single char flagged",
input: "a ___X___ here",
wantHint: true,
},
{
name: "triple underscores inside fenced code not flagged",
input: "sample:\n```\nuse ___keyword___ carefully\n```",
wantHint: false,
},
{
name: "triple underscores inside inline code not flagged",
input: "the literal `___phrase___` marker",
wantHint: false,
},
{
name: "escaped triple underscores not flagged",
input: `literal \___phrase___ with escaped opener`,
wantHint: false,
},
// --- Underscore-bold wrapping asterisk-italic: __*text*__ ---
{
name: "underscore-bold wrapping asterisk-italic flagged",
input: "note: __*important*__ text",
wantHint: true,
},
{
name: "underscore-bold wrapping asterisk-italic inside fenced code not flagged",
input: "```\nnote: __*important*__ sample\n```",
wantHint: false,
},
{
name: "underscore-bold wrapping asterisk-italic inside inline code not flagged",
input: "literal `__*important*__` marker",
wantHint: false,
},
// --- Asterisk-italic wrapping underscore-bold: *__text__* ---
{
name: "asterisk-italic wrapping underscore-bold flagged",
input: "note: *__phrase__* text",
wantHint: true,
},
{
name: "asterisk-italic wrapping underscore-bold inside fenced code not flagged",
input: "```md\nnote: *__phrase__* sample\n```",
wantHint: false,
},
// --- Positive tests: real emphasis in prose coexisting with fake in code ---
{
// Underscore-variant in prose must still fire when an asterisk
// variant appears inside a code span — verifies the strip does
// not over-sanitize across the six regex alternatives.
name: "real triple underscores outside inline code still flag when asterisk variant is in code",
input: "real ___strong___ and literal `***shape***` in code",
wantHint: true,
},
{
// Longer close fence closes properly; real ***emphasis*** after
// the fence must fire.
name: "real emphasis after a fence closed by longer marker still flags",
input: "```\nliteral ***phrase*** in code\n````\n\nand then real ***phrase*** after",
wantHint: true,
},
{
// 4-space indented "```" is an indented code block, not a fence
// open. The fence helper should refuse it; emphasis outside the
// (non-existent) fence must still be detected.
name: "four-space indented fence-like line does not open a fence for the emphasis check",
input: "prose\n\n ```\n not a fence\n ```\n\nreal ***strong*** here",
wantHint: true,
},
{
// 3-space indented fence is valid per CommonMark. Emphasis inside
// must be sanitized away, so the check must not fire.
name: "three-space indented fence still hides triple-asterisk inside",
input: " ```\n literal ***text*** inside\n ```",
wantHint: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkDocsUpdateBoldItalic(tt.input)
hasHint := got != ""
if hasHint != tt.wantHint {
t.Fatalf("checkDocsUpdateBoldItalic(%q) = %q, wantHint=%v", tt.input, got, tt.wantHint)
}
})
}
}
func TestDocsUpdateWarningsAggregates(t *testing.T) {
t.Parallel()
// Both flags trigger: replace_range with blank line AND triple-asterisk.
warnings := docsUpdateWarnings("replace_range", "***opening***\n\nsecond paragraph")
if len(warnings) != 2 {
t.Fatalf("expected 2 warnings, got %d: %v", len(warnings), warnings)
}
}
func TestDocsUpdateWarningsEmpty(t *testing.T) {
t.Parallel()
// Clean markdown in a non-replace mode produces zero warnings.
warnings := docsUpdateWarnings("insert_before", "plain paragraph text")
if len(warnings) != 0 {
t.Fatalf("expected no warnings, got: %v", warnings)
}
}

View File

@@ -3,9 +3,12 @@
package doc
import (
"reflect"
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ── V2 tests ──
@@ -31,199 +34,102 @@ func TestValidCommandsV2(t *testing.T) {
}
}
// ── V1 tests ──
func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) {
for _, apiVersion := range []string{"v1", "v2"} {
t.Run(apiVersion, func(t *testing.T) {
t.Parallel()
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, apiVersion, nil)
if err := validateUpdateV2(context.Background(), runtime); err != nil {
t.Fatalf("validateUpdateV2() error = %v", err)
}
msg := selectionRequiredMessageV1("replace_all")
for _, needle := range []string{
"--replace_all mode requires --selection-with-ellipsis or --selection-by-title",
"replace the entire document body",
"--mode overwrite",
} {
if !strings.Contains(msg, needle) {
t.Fatalf("message missing %q: %s", needle, msg)
}
dry := decodeDocDryRun(t, DocsUpdate.DryRun(context.Background(), runtime))
if len(dry.API) != 1 {
t.Fatalf("expected 1 dry-run API call, got %d", len(dry.API))
}
if got, want := dry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnUpdateDryRun"; got != want {
t.Fatalf("dry-run URL = %q, want %q", got, want)
}
if got, want := dry.API[0].Body["command"], "block_insert_after"; got != want {
t.Fatalf("dry-run command = %#v, want %q", got, want)
}
if got, want := dry.API[0].Body["block_id"], "-1"; got != want {
t.Fatalf("dry-run block_id = %#v, want %q", got, want)
}
})
}
}
func TestSelectionRequiredMessageV1OtherModesDoNotSuggestOverwrite(t *testing.T) {
t.Parallel()
msg := selectionRequiredMessageV1("replace_range")
if strings.Contains(msg, "--mode overwrite") {
t.Fatalf("replace_range message should not suggest overwrite: %s", msg)
}
if !strings.Contains(msg, "--replace_range mode requires --selection-with-ellipsis or --selection-by-title") {
t.Fatalf("unexpected message: %s", msg)
}
}
func TestIsWhiteboardCreateMarkdown(t *testing.T) {
t.Run("blank whiteboard tags", func(t *testing.T) {
markdown := "<whiteboard type=\"blank\"></whiteboard>\n<whiteboard type=\"blank\"></whiteboard>"
if !isWhiteboardCreateMarkdown(markdown) {
t.Fatalf("expected blank whiteboard markdown to be treated as whiteboard creation")
}
})
t.Run("mermaid code block", func(t *testing.T) {
markdown := "```mermaid\ngraph TD\nA-->B\n```"
if !isWhiteboardCreateMarkdown(markdown) {
t.Fatalf("expected mermaid markdown to be treated as whiteboard creation")
}
})
t.Run("plain markdown", func(t *testing.T) {
markdown := "## plain text"
if isWhiteboardCreateMarkdown(markdown) {
t.Fatalf("did not expect plain markdown to be treated as whiteboard creation")
}
})
}
func TestCheckOverwriteResourceBlocks(t *testing.T) {
t.Parallel()
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
tests := []struct {
name string
markdown string
wantWarn bool
wantSubs []string
setFlags map[string]string
want []string
}{
{
name: "empty markdown is clean",
markdown: "",
wantWarn: false,
},
{
name: "plain prose is clean",
markdown: "## Heading\n\nsome text",
wantWarn: false,
},
{
name: "single whiteboard triggers warning",
markdown: `<whiteboard token="abc123"/>`,
wantWarn: true,
wantSubs: []string{"1 whiteboard block", "overwrite"},
},
{
name: "multiple whiteboards counted",
markdown: "<whiteboard token=\"a\"/>\n<whiteboard token=\"b\"/>",
wantWarn: true,
wantSubs: []string{"2 whiteboard blocks"},
},
{
name: "single file attachment triggers warning",
markdown: `<file token="tok" name="report.pdf"/>`,
wantWarn: true,
wantSubs: []string{"1 file attachment block"},
},
{
name: "multiple file attachments counted",
markdown: "<file token=\"a\"/>\n<file token=\"b\"/>\n<file token=\"c\"/>",
wantWarn: true,
wantSubs: []string{"3 file attachment blocks"},
},
{
name: "whiteboard and file together both counted",
markdown: "<whiteboard token=\"wb\"/>\n<file token=\"f\"/>",
wantWarn: true,
wantSubs: []string{"1 whiteboard block", "1 file attachment block"},
name: "legacy mode",
setFlags: map[string]string{"mode": "overwrite"},
want: []string{
"docs +update is v2-only",
"the old v1 interface has been shut down",
"legacy v1 flag(s) --mode are no longer supported",
"--mode -> use --command",
"lark-cli skills read lark-doc references/lark-doc-update.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"follow the latest format rules",
"MUST NOT grep/open local SKILL.md files",
"lark-cli docs +update --help",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := checkOverwriteResourceBlocks(tt.markdown)
if (got != "") != tt.wantWarn {
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
err := validateUpdateV2(context.Background(), runtime)
if err == nil {
t.Fatal("expected v2-only validation error")
}
for _, sub := range tt.wantSubs {
if !strings.Contains(got, sub) {
t.Errorf("expected warning to contain %q, got: %s", sub, got)
for _, want := range tt.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
})
}
}
func TestNormalizeWhiteboardResult(t *testing.T) {
t.Run("adds empty board_tokens when whiteboard creation response omits it", func(t *testing.T) {
result := map[string]interface{}{
"success": true,
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("api-version", "", "")
cmd.Flags().String("doc", "doxcnUpdateDryRun", "")
cmd.Flags().String("doc-format", "xml", "")
cmd.Flags().String("command", "append", "")
cmd.Flags().Int("revision-id", -1, "")
cmd.Flags().String("content", "<p>hello</p>", "")
cmd.Flags().String("pattern", "", "")
cmd.Flags().String("block-id", "", "")
cmd.Flags().String("src-block-ids", "", "")
cmd.Flags().String("mode", "", "")
cmd.Flags().String("markdown", "", "")
cmd.Flags().String("selection-with-ellipsis", "", "")
cmd.Flags().String("selection-by-title", "", "")
cmd.Flags().String("new-title", "", "")
if apiVersion != "" {
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
t.Fatalf("set api-version: %v", err)
}
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
got, ok := result["board_tokens"].([]string)
if !ok {
t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"])
}
if len(got) != 0 {
t.Fatalf("expected empty board_tokens, got %#v", got)
}
})
t.Run("normalizes board_tokens to string slice", func(t *testing.T) {
result := map[string]interface{}{
"board_tokens": []interface{}{"board_1", "board_2"},
}
normalizeWhiteboardResult(result, "<whiteboard type=\"blank\"></whiteboard>")
want := []string{"board_1", "board_2"}
got, ok := result["board_tokens"].([]string)
if !ok {
t.Fatalf("expected board_tokens to be []string, got %T", result["board_tokens"])
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("board_tokens mismatch: got %#v want %#v", got, want)
}
})
t.Run("leaves non whiteboard response unchanged", func(t *testing.T) {
result := map[string]interface{}{
"success": true,
}
normalizeWhiteboardResult(result, "## plain text")
if _, ok := result["board_tokens"]; ok {
t.Fatalf("did not expect board_tokens for non-whiteboard markdown")
}
})
}
func TestValidateSelectionByTitleV1(t *testing.T) {
t.Parallel()
tests := []struct {
name string
title string
wantErr bool
errSub string
}{
{name: "empty title is valid", title: "", wantErr: false},
{name: "single heading is valid", title: "## Section", wantErr: false},
{name: "h1 heading is valid", title: "# Top", wantErr: false},
{name: "deep heading is valid", title: "### Sub-section", wantErr: false},
{name: "missing hash prefix is invalid", title: "No hash", wantErr: true, errSub: "'#'"},
{name: "multiline title is invalid", title: "## First\n## Second", wantErr: true, errSub: "single"},
{name: "title with embedded carriage return is invalid", title: "## Title\r## Next", wantErr: true, errSub: "single"},
{name: "leading-space heading is valid after trim", title: " ## Section", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSelectionByTitleV1(tt.title)
if (err != nil) != tt.wantErr {
t.Fatalf("validateSelectionByTitleV1(%q) error = %v, wantErr = %v", tt.title, err, tt.wantErr)
}
if tt.wantErr && tt.errSub != "" && !strings.Contains(err.Error(), tt.errSub) {
t.Errorf("expected error to contain %q, got: %v", tt.errSub, err)
}
})
for name, value := range setFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
}
}
return common.TestNewRuntimeContext(cmd, nil)
}

View File

@@ -24,13 +24,13 @@ var validCommandsV2 = map[string]bool{
// v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path.
func v2UpdateFlags() []common.Flag {
return []common.Flag{
{Name: "command", Desc: "operation: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", Hidden: true, Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format (prefer XML)", Hidden: true, Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "new content (XML or Markdown)", Hidden: true, Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "regex pattern for str_replace", Hidden: true},
{Name: "block-id", Desc: "target block ID for block_* operations", Hidden: true},
{Name: "src-block-ids", Desc: "source block IDs (comma-separated) for block_copy_insert_after / block_move_after", Hidden: true},
{Name: "revision-id", Desc: "base revision (-1 = latest)", Hidden: true, Type: "int", Default: "-1"},
{Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()},
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
{Name: "block-id", Desc: "target anchor/block id for block operations; -1 means document end where supported"},
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
}
}
@@ -39,6 +39,9 @@ func validCommandsV2Keys() []string {
}
func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
if err := validateDocsV2Only(runtime, "+update", docsUpdateLegacyFlags()); err != nil {
return err
}
if _, err := parseDocumentRef(runtime.Str("doc")); err != nil {
return common.FlagErrorf("invalid --doc: %v", err)
}

View File

@@ -1,649 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"io"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// fixExportedMarkdown applies post-processing to Lark-exported Markdown to
// improve round-trip fidelity on re-import:
//
// 1. fixBoldSpacing: removes trailing whitespace before closing ** / *,
// and strips redundant ** from ATX headings. Applied only outside fenced
// code blocks, and skips inline code spans.
//
// 2. normalizeNestedListIndentation: rewrites space-pair-indented nested list
// markers to tab-indented markers. This avoids nested ordered list items
// being flattened or interpreted as plain text/code on re-import.
//
// 3. fixSetextAmbiguity: inserts a blank line before any "---" that immediately
// follows a non-empty line, preventing it from being parsed as a Setext H2.
// Applied only outside fenced code blocks.
//
// 4. fixBlockquoteHardBreaks: inserts a blank blockquote line (">") between
// consecutive blockquote content lines so create-doc preserves line breaks.
// Applied only outside fenced code blocks.
//
// 5. fixTopLevelSoftbreaks: inserts a blank line between adjacent non-empty
// lines at the top level and inside content containers (callout,
// quote-container, lark-td). Code fences are left untouched, and
// consecutive list items / continuations are not separated.
//
// 6. fixCalloutEmoji: replaces named emoji aliases (e.g. emoji="warning") with
// actual Unicode emoji characters that create-doc understands. Applied only
// outside fenced code blocks.
func fixExportedMarkdown(md string) string {
md = applyOutsideCodeFences(md, fixBoldSpacing)
md = applyOutsideCodeFences(md, normalizeNestedListIndentation)
md = applyOutsideCodeFences(md, fixSetextAmbiguity)
md = applyOutsideCodeFences(md, fixBlockquoteHardBreaks)
md = fixTopLevelSoftbreaks(md)
md = applyOutsideCodeFences(md, fixCalloutEmoji)
// Collapse runs of 3+ consecutive newlines into exactly 2 (one blank line),
// but only outside fenced code blocks to preserve intentional blank lines in code.
md = applyOutsideCodeFences(md, func(s string) string {
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
return s
})
md = strings.TrimRight(md, "\n") + "\n"
return md
}
// applyOutsideCodeFences applies fn only to content outside fenced code blocks.
// Lines inside fenced code blocks (``` ... ```) are passed through unchanged,
// preventing transforms from corrupting literal code content.
func applyOutsideCodeFences(md string, fn func(string) string) string {
lines := strings.Split(md, "\n")
var out []string
var chunk []string
inCode := false
flush := func() {
if len(chunk) == 0 {
return
}
out = append(out, strings.Split(fn(strings.Join(chunk, "\n")), "\n")...)
chunk = chunk[:0]
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
if !inCode {
flush()
inCode = true
} else if trimmed == "```" {
inCode = false
}
out = append(out, line)
continue
}
if inCode {
out = append(out, line)
} else {
chunk = append(chunk, line)
}
}
flush()
return strings.Join(out, "\n")
}
// fixBlockquoteHardBreaks inserts a blank blockquote line (">") between
// consecutive blockquote content lines. This forces each line into its own
// paragraph within the blockquote, so MCP create-doc preserves line breaks
// instead of collapsing them into a single paragraph.
//
// Before: "> line1\n> line2" → After: "> line1\n>\n> line2"
func fixBlockquoteHardBreaks(md string) string {
lines := strings.Split(md, "\n")
out := make([]string, 0, len(lines)*2)
for i, line := range lines {
out = append(out, line)
if strings.HasPrefix(line, "> ") && i+1 < len(lines) && strings.HasPrefix(lines[i+1], "> ") {
out = append(out, ">")
}
}
return strings.Join(out, "\n")
}
// fixBoldSpacing normalizes emphasis markers exported by Lark while preserving
// inline code spans:
//
// 1. Removes leading whitespace after opening ** and * delimiters:
// "** text**" → "**text**", "* text*" → "*text*"
//
// 2. Removes trailing whitespace before closing ** and * delimiters:
// "**text **" → "**text**", "*text *" → "*text*"
//
// 3. Removes redundant bold around an entire ATX heading:
// "# **text**" → "# text"
//
// The bold and italic spacing fixes only run on non-code segments so literal
// code content is left unchanged.
var (
// headingBoldRe uses [^*]+ (no asterisks) to avoid mismatching headings
// that contain multiple disjoint bold spans such as "# **foo** and **bar**".
headingBoldRe = regexp.MustCompile(`(?m)^(#{1,6})\s+\*\*([^*]+)\*\*\s*$`)
)
func fixBoldSpacing(md string) string {
lines := strings.Split(md, "\n")
for i, line := range lines {
lines[i] = fixBoldSpacingLine(line)
}
md = strings.Join(lines, "\n")
md = headingBoldRe.ReplaceAllString(md, "$1 $2")
return md
}
// atxHeadingRe matches ATX heading lines (# ... through ###### ...).
var atxHeadingRe = regexp.MustCompile(`^#{1,6}\s`)
// scanInlineCodeSpans returns the byte ranges [start, end) of all inline code
// spans in line. It handles multi-backtick delimiters (e.g. “ `foo` “) by
// finding the opening run of N backticks and searching for the next identical
// run to close the span, per CommonMark spec §6.1.
func scanInlineCodeSpans(line string) [][2]int {
var spans [][2]int
i := 0
for i < len(line) {
if line[i] != '`' {
i++
continue
}
// Count the opening backtick run.
start := i
for i < len(line) && line[i] == '`' {
i++
}
delim := line[start:i] // e.g. "`" or "``" or "```"
// Search for the closing run of the same length.
j := i
for j <= len(line)-len(delim) {
if line[j] == '`' {
k := j
for k < len(line) && line[k] == '`' {
k++
}
if k-j == len(delim) {
spans = append(spans, [2]int{start, k})
i = k
break
}
j = k // skip this backtick run and keep searching
} else {
j++
}
}
// No closing delimiter found — not a code span, continue.
}
return spans
}
// fixBoldSpacingLine applies bold/italic trailing-space fixes to a single line,
// skipping content inside inline code spans to avoid corrupting literal code.
// ATX heading lines are also skipped here because headingBoldRe in fixBoldSpacing
// handles them separately, keeping heading-only normalization isolated from the
// inline emphasis spacing scanner below.
func fixBoldSpacingLine(line string) string {
if atxHeadingRe.MatchString(line) {
return line
}
spans := scanInlineCodeSpans(line)
if len(spans) == 0 {
return fixEmphasisSpacingSegment(line)
}
var sb strings.Builder
pos := 0
for _, loc := range spans {
// Process the non-code segment before this inline code span.
seg := line[pos:loc[0]]
sb.WriteString(fixEmphasisSpacingSegment(seg))
// Preserve inline code span as-is.
sb.WriteString(line[loc[0]:loc[1]])
pos = loc[1]
}
// Remaining non-code segment after the last code span.
sb.WriteString(fixEmphasisSpacingSegment(line[pos:]))
return sb.String()
}
// fixEmphasisSpacingSegment trims only the whitespace immediately inside simple
// *...* and **...** spans. It deliberately ignores runs of 3+ asterisks and
// any candidate whose payload contains another asterisk so nested emphasis-like
// text remains untouched. When both inner sides contain whitespace, single-rune
// payloads are preserved as literal text (for example "* x *" and "** x **").
func fixEmphasisSpacingSegment(seg string) string {
if !strings.Contains(seg, "*") {
return seg
}
var sb strings.Builder
pos := 0
for pos < len(seg) {
openStart, openEnd, ok := nextAsteriskRun(seg, pos)
if !ok {
sb.WriteString(seg[pos:])
break
}
sb.WriteString(seg[pos:openStart])
markerLen := openEnd - openStart
if markerLen != 1 && markerLen != 2 {
sb.WriteString(seg[openStart:openEnd])
pos = openEnd
continue
}
closeStart, closeEnd, ok := nextAsteriskRun(seg, openEnd)
if !ok || closeEnd-closeStart != markerLen {
sb.WriteString(seg[openStart:openEnd])
pos = openEnd
continue
}
payload := seg[openEnd:closeStart]
normalized, shouldNormalize := normalizeEmphasisPayload(payload)
if !shouldNormalize {
sb.WriteString(seg[openStart:closeEnd])
pos = closeEnd
continue
}
marker := seg[openStart:openEnd]
sb.WriteString(marker)
sb.WriteString(normalized)
sb.WriteString(marker)
pos = closeEnd
}
return sb.String()
}
func nextAsteriskRun(s string, start int) (runStart, runEnd int, ok bool) {
for i := start; i < len(s); i++ {
if s[i] != '*' {
continue
}
j := i
for j < len(s) && s[j] == '*' {
j++
}
return i, j, true
}
return 0, 0, false
}
func normalizeEmphasisPayload(payload string) (string, bool) {
trimmedLeft := strings.TrimLeftFunc(payload, unicode.IsSpace)
trimmed := strings.TrimRightFunc(trimmedLeft, unicode.IsSpace)
if trimmed == "" {
return payload, false
}
hasLeadingSpace := len(trimmedLeft) != len(payload)
hasTrailingSpace := len(trimmed) != len(trimmedLeft)
if !hasLeadingSpace && !hasTrailingSpace {
return payload, true
}
if hasLeadingSpace && hasTrailingSpace && utf8.RuneCountInString(trimmed) == 1 {
return payload, false
}
return trimmed, true
}
var setextRe = regexp.MustCompile(`(?m)^([^\n]+)\n(-{3,}\s*$)`)
func fixSetextAmbiguity(md string) string {
return setextRe.ReplaceAllString(md, "$1\n\n$2")
}
// calloutTypeColors maps the semantic type= shorthand to a recommended
// [background-color, border-color] pair for Feishu callout blocks.
// Used only for hint messages — the Markdown itself is never rewritten.
var calloutTypeColors = map[string][2]string{
"warning": {"light-yellow", "yellow"},
"caution": {"light-orange", "orange"},
"note": {"light-blue", "blue"},
"info": {"light-blue", "blue"},
"tip": {"light-green", "green"},
"success": {"light-green", "green"},
"check": {"light-green", "green"},
"error": {"light-red", "red"},
"danger": {"light-red", "red"},
"important": {"light-purple", "purple"},
}
// calloutOpenTagRe matches a <callout …> opening tag.
var calloutOpenTagRe = regexp.MustCompile(`<callout(\s[^>]*)?>`)
// calloutTypeAttrRe extracts the value of a type= attribute (single or
// double quoted) from a callout opening tag's attribute string. The
// (?:^|\s) anchor instead of \b is intentional: \b sits at any
// word/non-word boundary, and `-` is a non-word character, so
// `\btype=` would also match the suffix of `data-type=` and yield a
// bogus type lookup. Anchoring on start-of-string-or-whitespace
// requires a real attribute separator before the name.
var calloutTypeAttrRe = regexp.MustCompile(`(?:^|\s)type=(?:"([^"]*)"|'([^']*)')`)
// calloutBackgroundColorAttrRe matches a background-color= attribute
// name with optional whitespace around the equals sign, so forms like
// `background-color="..."` and `background-color = "..."` are both
// accepted. Same (?:^|\s) anchor as calloutTypeAttrRe, for the same
// reason: `data-background-color="..."` must not look like a present
// background-color and silently suppress the hint.
var calloutBackgroundColorAttrRe = regexp.MustCompile(`(?:^|\s)background-color\s*=`)
// WarnCalloutType scans md for callout tags that carry a type= attribute but
// no background-color= attribute, then writes a hint line to w for each one
// suggesting the explicit Feishu color attributes to use instead.
//
// Callout tags inside fenced code blocks (``` or ~~~) are skipped — they
// are documentation samples, not real callouts the user wants Feishu to
// render. Fence detection uses the shared codeFenceOpenMarker /
// isCodeFenceClose helpers so both backtick and tilde fences are handled
// (matching CommonMark §4.5).
//
// The Markdown is not modified — the caller is responsible for acting on
// the hints or ignoring them. This keeps the create/update path
// transparent: user input reaches create-doc exactly as written.
func WarnCalloutType(md string, w io.Writer) {
fenceMarker := ""
for _, line := range strings.Split(md, "\n") {
if fenceMarker != "" {
// Inside a fenced block — skip everything until the matching
// closer. Code samples that show literal <callout type=...>
// must not produce a phantom hint.
if isCodeFenceClose(line, fenceMarker) {
fenceMarker = ""
}
continue
}
if marker := codeFenceOpenMarker(line); marker != "" {
fenceMarker = marker
continue
}
scanCalloutTagsForWarning(line, w)
}
}
// scanCalloutTagsForWarning emits a hint to w for every <callout type="...">
// tag in s that lacks an explicit background-color= attribute. Pulled out
// of WarnCalloutType so the line walker only handles fence state and the
// per-tag scan is its own readable unit.
//
// The previous implementation routed the tag iteration through
// calloutOpenTagRe.ReplaceAllStringFunc with a callback that always
// returned the original tag and threw the rebuilt string away — using a
// rewrite primitive purely for its iteration side-effect, plus a second
// regex execution to recover the capture groups inside the callback.
// FindAllStringSubmatch hands us both the iteration and the groups in one
// pass, no allocation thrown away.
func scanCalloutTagsForWarning(s string, w io.Writer) {
for _, m := range calloutOpenTagRe.FindAllStringSubmatch(s, -1) {
attrs := m[1]
// Skip tags that already carry an explicit background-color.
if calloutBackgroundColorAttrRe.MatchString(attrs) {
continue
}
parts := calloutTypeAttrRe.FindStringSubmatch(attrs)
if len(parts) < 3 {
continue // no type= attribute
}
// parts[1] is the double-quoted capture, parts[2] is single-quoted.
typeName := parts[1]
if typeName == "" {
typeName = parts[2]
}
colors, ok := calloutTypeColors[typeName]
if !ok {
continue // unknown type — no hint to give
}
fmt.Fprintf(w,
"hint: callout type=%q has no background-color; consider: background-color=%q border-color=%q\n",
typeName, colors[0], colors[1])
}
}
// calloutEmojiAliases maps named emoji strings that fetch-doc emits to actual
// Unicode emoji characters that create-doc accepts.
var calloutEmojiAliases = map[string]string{
"warning": "⚠️",
"note": "📝",
"tip": "💡",
"info": "",
"check": "✅",
"success": "✅",
"error": "❌",
"danger": "🚨",
"important": "❗",
"caution": "⚠️",
"question": "❓",
"forbidden": "🚫",
"fire": "🔥",
"star": "⭐",
"pin": "📌",
"clock": "🕐",
"gift": "🎁",
"eyes": "👀",
"bulb": "💡",
"memo": "📝",
"link": "🔗",
"key": "🔑",
"lock": "🔒",
"thumbsup": "👍",
"thumbsdown": "👎",
"rocket": "🚀",
"construction": "🚧",
}
// calloutEmojiRe matches emoji="<name>" in callout opening tags.
var calloutEmojiRe = regexp.MustCompile(`(<callout[^>]*\bemoji=")([^"]+)(")`)
// fixCalloutEmoji replaces named emoji aliases in callout tags with actual
// Unicode emoji characters. fetch-doc sometimes emits emoji="warning" instead
// of emoji="⚠️"; create-doc only accepts Unicode emoji.
func fixCalloutEmoji(md string) string {
return calloutEmojiRe.ReplaceAllStringFunc(md, func(match string) string {
parts := calloutEmojiRe.FindStringSubmatch(match)
if len(parts) != 4 {
return match
}
name := parts[2]
if emoji, ok := calloutEmojiAliases[name]; ok {
return parts[1] + emoji + parts[3]
}
return match
})
}
// isTableStructuralTag returns true for lark-table tags that are structural
// (table/tr/td open/close) and should not themselves trigger blank-line insertion.
func isTableStructuralTag(s string) bool {
return strings.HasPrefix(s, "<lark-t") ||
strings.HasPrefix(s, "</lark-t")
}
// contentContainers lists block tags whose interior should have blank lines
// inserted between adjacent content lines (same treatment as lark-td).
var contentContainers = [][2]string{
{"<lark-td>", "</lark-td>"},
{"<callout", "</callout>"},
{"<quote-container>", "</quote-container>"},
}
// listItemRe matches unordered and ordered list item markers, including
// indented (nested) items.
var listItemRe = regexp.MustCompile(`^[ \t]*([-*+]|\d+[.)]) `)
// nestedListIndentRe matches nested list item markers indented with pairs of
// spaces. We rewrite those space pairs to tabs because some downstream
// round-trip paths treat multi-space indented ordered items as flat items or
// literal text, while tab indentation remains nested and avoids 4-space code
// block ambiguity.
var nestedListIndentRe = regexp.MustCompile(`^( {2,})([-*+]|\d+[.)]) `)
func normalizeNestedListIndentation(md string) string {
lines := strings.Split(md, "\n")
for i, line := range lines {
matches := nestedListIndentRe.FindStringSubmatch(line)
if len(matches) != 3 {
continue
}
if !hasPreviousNonBlankListItem(lines, i) {
continue
}
indent := matches[1]
if len(indent)%2 != 0 {
continue
}
tabs := strings.Repeat("\t", len(indent)/2)
lines[i] = tabs + line[len(indent):]
}
return strings.Join(lines, "\n")
}
func hasPreviousNonBlankListItem(lines []string, index int) bool {
for i := index - 1; i >= 0; i-- {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" {
return false
}
return listItemRe.MatchString(lines[i])
}
return false
}
// isListItemOrContinuation returns true for lines that are part of a list:
// either a list item marker line or an indented continuation of a list item.
// This is used to prevent blank lines being inserted between tight list lines,
// which would turn a tight list into a loose list and change rendering.
func isListItemOrContinuation(line string) bool {
if listItemRe.MatchString(line) {
return true
}
// Continuation lines are indented by at least 2 spaces or 1 tab.
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
}
// fixTopLevelSoftbreaks ensures that adjacent non-empty content lines are
// separated by a blank line in the following contexts:
// 1. Top level (depth == 0): every Lark block becomes its own Markdown paragraph.
// 2. Inside content containers (<lark-td>, <callout>, <quote-container>):
// multi-line content is preserved as separate paragraphs.
//
// Structural table tags (<lark-table>, <lark-tr>, <lark-td> and their closing
// counterparts) never trigger blank-line insertion themselves. Fenced code
// blocks (``` ... ```) are left completely untouched. Consecutive list items
// and list continuations are not separated (to preserve tight lists).
func fixTopLevelSoftbreaks(md string) string {
lines := strings.Split(md, "\n")
out := make([]string, 0, len(lines)*2)
inCodeBlock := false
// containerDepth > 0 means we are inside a content container.
containerDepth := 0
// tableDepth tracks <lark-table> nesting (outer structure, not content).
tableDepth := 0
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// --- Track fenced code blocks — skip all processing inside. ---
// Any ``` line opens a block; only plain ``` (no language id) closes it.
if strings.HasPrefix(trimmed, "```") {
if inCodeBlock {
if trimmed == "```" {
inCodeBlock = false
}
} else {
inCodeBlock = true
}
out = append(out, line)
continue
}
if !inCodeBlock {
// --- Track content containers. ---
for _, cc := range contentContainers {
if strings.HasPrefix(trimmed, cc[0]) {
containerDepth++
}
if strings.Contains(trimmed, cc[1]) {
containerDepth--
if containerDepth < 0 {
containerDepth = 0
}
}
}
// --- Track table structure (outer, non-content). ---
if strings.HasPrefix(trimmed, "<lark-table") {
tableDepth++
}
if strings.Contains(trimmed, "</lark-table>") {
tableDepth--
if tableDepth < 0 {
tableDepth = 0
}
}
}
// --- Decide whether to insert a blank line before this line. ---
if !inCodeBlock && trimmed != "" && i > 0 {
// Skip structural table tags — they are not content lines.
isStructural := isTableStructuralTag(trimmed)
// Don't split consecutive blockquote lines ("> ...") — they form
// one continuous blockquote in the original document.
isBlockquote := strings.HasPrefix(trimmed, "> ") || trimmed == ">"
// Only closing container tags suppress blank-line insertion.
// Opening container tags may still receive a blank line before them
// (e.g. two consecutive <callout> blocks need a blank between them).
isContainerTag := false
for _, cc := range contentContainers {
closingTag := "</" + cc[0][1:]
if strings.HasPrefix(trimmed, closingTag) {
isContainerTag = true
break
}
}
// Insert blank line when:
// - at top level (tableDepth == 0, containerDepth == 0), OR
// - inside a content container (containerDepth > 0, not in outer table)
// AND this line is actual content (not structural/blockquote/container-tag).
inContent := tableDepth == 0 || containerDepth > 0
if !isStructural && !isBlockquote && !isContainerTag && inContent {
// Don't split consecutive list items / continuations — inserting a
// blank line between them turns a tight list into a loose list.
isListRelated := isListItemOrContinuation(line)
prevIsListRelated := len(out) > 0 && isListItemOrContinuation(out[len(out)-1])
if !(isListRelated && prevIsListRelated) {
prev := ""
if len(out) > 0 {
prev = strings.TrimSpace(out[len(out)-1])
}
if prev != "" && !isTableStructuralTag(prev) {
out = append(out, "")
}
}
}
}
out = append(out, line)
}
return strings.Join(out, "\n")
}

View File

@@ -1,287 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
)
// TestFixExportedMarkdownIdempotent asserts the core promise of the exported
// markdown pipeline: applying the fixes twice produces the same result as
// applying them once. Round-trip formatting relies on this invariant, so any
// transform that keeps rewriting its own output would break fetch → edit →
// update → fetch stability.
func TestFixExportedMarkdownIdempotent(t *testing.T) {
fixtures := map[string]string{
"kitchen sink": strings.Join([]string{
"# **Title**",
"paragraph one",
"paragraph two",
"**bold ** and * italic*",
"",
"> q1",
"> q2",
"",
"1. parent",
" 1. child",
" 1. grandchild",
"",
"<callout emoji=\"warning\">",
"callout body line 1",
"callout body line 2",
"</callout>",
"",
"some text",
"---",
"",
"```go",
"// code content with markdown-like shapes must survive as-is",
"**foo **",
"* hello*",
" 1. nested",
"> q",
"---",
"```",
"",
}, "\n"),
"cjk content": strings.Join([]string{
"# **测试标题**",
"段落一",
"段落二",
"**有用性 ** and * 关键 *",
"",
"1. 父项",
" 1. 子项",
"",
}, "\n"),
"nested containers": strings.Join([]string{
"<callout emoji=\"info\">",
"line a",
"line b",
"</callout>",
"",
"<quote-container>",
"quoted 1",
"quoted 2",
"</quote-container>",
"",
}, "\n"),
}
for name, fixture := range fixtures {
t.Run(name, func(t *testing.T) {
once := fixExportedMarkdown(fixture)
twice := fixExportedMarkdown(once)
if once != twice {
t.Errorf("fixExportedMarkdown is not idempotent for %q\nfirst pass:\n%s\nsecond pass:\n%s",
name, once, twice)
}
})
}
}
// TestFixExportedMarkdownPreservesFencedCodeByteForByte packs a fenced code
// block with content that every individual transform in the pipeline would
// normally rewrite, and asserts the fence content comes out byte-for-byte
// identical. This is the pipeline's strongest invariant — users' code samples
// must never be silently modified by a formatting pass.
func TestFixExportedMarkdownPreservesFencedCodeByteForByte(t *testing.T) {
// Every line below is something at least one transform would touch if it
// appeared outside a fence. None of it must change.
dangerous := strings.Join([]string{
"**foo **", // fixBoldSpacing — trailing space bold
"* hello*", // fixBoldSpacing — leading space italic
"# **heading**", // fixBoldSpacing — redundant heading bold
"para1", // fixTopLevelSoftbreaks — adjacent paragraphs
"para2",
"> q1", // fixBlockquoteHardBreaks — blockquote pair
"> q2",
"some text", // fixSetextAmbiguity — text before ---
"---",
" 1. nested", // normalizeNestedListIndentation
`<callout emoji="warning">`, // fixCalloutEmoji — emoji alias
}, "\n")
// Wrap the dangerous content in a triple-backtick fence and surround with
// content so the pipeline has adjacent regions to potentially touch.
input := "before\n\n```\n" + dangerous + "\n```\n\nafter\n"
got := fixExportedMarkdown(input)
// Extract the fence content from the output and compare to the input fence
// content byte-for-byte.
gotFence, ok := extractFirstFenceContent(got)
if !ok {
t.Fatalf("fixExportedMarkdown output lost its fenced code block:\n%s", got)
}
if gotFence != dangerous {
t.Errorf("fenced code content was modified\nwant (bytes): %q\ngot (bytes): %q",
dangerous, gotFence)
}
}
// extractFirstFenceContent returns the inner text of the first triple-backtick
// fenced code block it finds, or ("", false) if none is present.
func extractFirstFenceContent(md string) (string, bool) {
const fence = "```"
open := strings.Index(md, fence)
if open < 0 {
return "", false
}
// Skip the fence marker and its info-string line.
rest := md[open+len(fence):]
lineEnd := strings.Index(rest, "\n")
if lineEnd < 0 {
return "", false
}
rest = rest[lineEnd+1:]
close := strings.Index(rest, "\n"+fence)
if close < 0 {
return "", false
}
return rest[:close], true
}
// TestFixExportedMarkdownPreservesCRLF feeds CRLF-terminated markdown (Windows
// line endings) through the pipeline and asserts that line endings are
// preserved AND the emphasis/heading transforms still apply — neither
// silently-LF-normalized nor passed through unchanged.
func TestFixExportedMarkdownPreservesCRLF(t *testing.T) {
lf := "# **Title**\nparagraph one\nparagraph two\n**bold **\n"
crlf := strings.ReplaceAll(lf, "\n", "\r\n")
got := fixExportedMarkdown(crlf)
// Transforms must still fire: heading bold stripped, trailing-space bold trimmed.
if strings.Contains(got, "**Title**") {
t.Errorf("heading bold not stripped on CRLF input:\n%q", got)
}
if strings.Contains(got, "**bold **") {
t.Errorf("trailing-space bold not fixed on CRLF input:\n%q", got)
}
// CRLF line endings must survive — we don't want to silently normalize a
// Windows author's document to LF.
if !strings.Contains(got, "\r\n") {
t.Errorf("CRLF line endings were normalized away:\n%q", got)
}
}
// TestFixExportedMarkdownTransformInteractions covers shapes where more than
// one transform fires on the same input. Each transform is individually tested
// elsewhere; these cases guard against composition regressions.
func TestFixExportedMarkdownTransformInteractions(t *testing.T) {
tests := []struct {
name string
input string
wantContains []string // substrings that must be present after fixes
wantAbsent []string // substrings that must be absent after fixes
}{
{
name: "nested list item with trailing-space bold",
input: "1. parent\n 1. **child **\n",
wantContains: []string{
"\t1.", // nested indent converted to tab
"**child**", // trailing space trimmed
},
wantAbsent: []string{
" 1.", // original two-space indent gone
"**child **", // original trailing space gone
},
},
{
name: "paragraph followed by list",
input: "paragraph\n- item a\n- item b\n",
wantContains: []string{
"paragraph\n\n- item a", // blank line inserted at text-to-list transition
},
wantAbsent: []string{
"\n\n\n", // no triple newline
},
},
{
name: "callout containing list with emphasis",
input: "<callout emoji=\"info\">\n- **item **\n- another\n</callout>\n",
wantContains: []string{
"**item**", // trailing-space bold fixed inside callout
},
wantAbsent: []string{
"**item **",
},
},
{
name: "heading followed by paragraph with bold",
input: "# **Title**\nbody **text **\n",
wantContains: []string{
"# Title", // heading bold stripped
"body **text**", // paragraph bold trimmed, not stripped
},
wantAbsent: []string{
"# **Title**",
"body **text **",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixExportedMarkdown(tt.input)
for _, want := range tt.wantContains {
if !strings.Contains(got, want) {
t.Errorf("want substring %q not found in output:\n%s", want, got)
}
}
for _, unwanted := range tt.wantAbsent {
if strings.Contains(got, unwanted) {
t.Errorf("unwanted substring %q still present in output:\n%s", unwanted, got)
}
}
})
}
}
// TestNormalizeNestedListIndentationDocumentedSkips locks in the deliberate
// "do nothing" branches of normalizeNestedListIndentation. Each case below is
// a shape the function intentionally does not rewrite; if a future change to
// the heuristic flips one of these, we want the regression to be visible in
// the test diff rather than silently changing user documents.
func TestNormalizeNestedListIndentationDocumentedSkips(t *testing.T) {
tests := []struct {
name string
input string
// want is identical to input — we are asserting "no change".
}{
{
name: "three-space indent (odd) under list item stays unchanged",
input: "1. parent\n 1. child",
},
{
name: "five-space indent (odd) under list item stays unchanged",
input: "- parent\n - deep",
},
{
name: "two-space indent without a parent list item stays unchanged",
input: "plain paragraph\n - not nested",
},
{
name: "blank-line-separated loose-list sibling stays unchanged",
input: "1. a\n\n 1. b",
},
{
name: "four-space indented code block under list item stays unchanged",
input: "- parent\n\n 1. code sample",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeNestedListIndentation(tt.input)
if got != tt.input {
t.Errorf("normalizeNestedListIndentation unexpectedly rewrote documented-skip input\ninput: %q\ngot: %q", tt.input, got)
}
})
}
}

View File

@@ -1,569 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
)
func TestFixBoldSpacing(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "leading space after opening bold",
input: "** hello**",
want: "**hello**",
},
{
name: "leading space after opening italic",
input: "* hello*",
want: "*hello*",
},
{
name: "leading and trailing spaces inside bold are collapsed",
input: "** hello **",
want: "**hello**",
},
{
name: "leading and trailing spaces inside italic are collapsed",
input: "* hello *",
want: "*hello*",
},
{
name: "multiple spaced italic spans on one line are each collapsed",
input: "* a* * b*",
want: "*a* *b*",
},
{
name: "ambiguous italic span stays literal",
input: "2 * x * y",
want: "2 * x * y",
},
{
name: "ambiguous bold span stays literal",
input: "2 ** x ** y",
want: "2 ** x ** y",
},
{
name: "single-rune italic with spaces on both sides stays literal",
input: "* x *",
want: "* x *",
},
{
name: "single-rune bold with spaces on both sides stays literal",
input: "** x **",
want: "** x **",
},
{
name: "triple-asterisk near miss stays literal",
input: "*** hello**",
want: "*** hello**",
},
{
name: "trailing space before closing bold",
input: "**hello **",
want: "**hello**",
},
{
name: "trailing space before closing italic",
input: "*hello *",
want: "*hello*",
},
{
name: "redundant bold in h1",
input: "# **Title**",
want: "# Title",
},
{
name: "redundant bold in h2",
input: "## **Section**",
want: "## Section",
},
{
name: "no change needed for clean bold",
input: "**bold**",
want: "**bold**",
},
{
name: "multiple lines processed independently",
input: "**foo **\n**bar **",
want: "**foo**\n**bar**",
},
{
name: "inline code span not modified",
input: "`**hello **`",
want: "`**hello **`",
},
{
name: "inline code preserved, bold outside fixed",
input: "**foo ** and `**bar **`",
want: "**foo** and `**bar **`",
},
{
name: "inline code with spaced italic stays literal while outside span is fixed",
input: "`* hello *` and * hello *",
want: "`* hello *` and *hello*",
},
{
name: "opening space inside text tag fixed",
input: `<text color="red">** Helpful - 有用性:**</text>`,
want: `<text color="red">**Helpful - 有用性:**</text>`,
},
{
name: "double-backtick inline code not modified",
input: "``**hello **`` and **world **",
want: "``**hello **`` and **world**",
},
{
name: "double-backtick span containing literal backtick not modified",
input: "`` a`b `` and **bold **",
want: "`` a`b `` and **bold**",
},
{
name: "heading with multiple bold spans left unchanged",
input: "# **foo** and **bar**",
want: "# **foo** and **bar**",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixBoldSpacing(tt.input)
if got != tt.want {
t.Errorf("fixBoldSpacing(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixSetextAmbiguity(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "paragraph followed by ---",
input: "some text\n---",
want: "some text\n\n---",
},
{
name: "blank line before --- already",
input: "some text\n\n---",
want: "some text\n\n---",
},
{
name: "heading not affected",
input: "# Heading\n---",
want: "# Heading\n\n---",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixSetextAmbiguity(tt.input)
if got != tt.want {
t.Errorf("fixSetextAmbiguity(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixBlockquoteHardBreaks(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "two consecutive blockquote lines",
input: "> line1\n> line2",
want: "> line1\n>\n> line2",
},
{
name: "three consecutive blockquote lines",
input: "> a\n> b\n> c",
want: "> a\n>\n> b\n>\n> c",
},
{
name: "single blockquote line unchanged",
input: "> only one",
want: "> only one",
},
{
name: "non-blockquote not affected",
input: "line1\nline2",
want: "line1\nline2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixBlockquoteHardBreaks(tt.input)
if got != tt.want {
t.Errorf("fixBlockquoteHardBreaks(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixTopLevelSoftbreaks(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "adjacent top-level lines get blank line",
input: "paragraph one\nparagraph two",
want: "paragraph one\n\nparagraph two",
},
{
name: "lines inside code block not modified",
input: "```\nline1\nline2\n```",
want: "```\nline1\nline2\n```",
},
{
// callout is a content container: blank lines are inserted between inner lines.
name: "lines inside callout get blank line between them",
input: "<callout>\nline1\nline2\n</callout>",
want: "<callout>\n\nline1\n\nline2\n</callout>",
},
{
name: "lark-td cell content gets blank line",
input: "<lark-td>\nline1\nline2\n</lark-td>",
want: "<lark-td>\nline1\n\nline2\n</lark-td>",
},
{
name: "structural lark-table tags not separated",
input: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
want: "<lark-table>\n<lark-tr>\n<lark-td>\ncontent\n</lark-td>\n</lark-tr>\n</lark-table>",
},
{
name: "blockquote lines not split",
input: "> line1\n> line2",
want: "> line1\n> line2",
},
{
name: "consecutive unordered list items not split",
input: "- item a\n- item b\n- item c",
want: "- item a\n- item b\n- item c",
},
{
name: "consecutive ordered list items not split",
input: "1. first\n2. second\n3. third",
want: "1. first\n2. second\n3. third",
},
{
name: "list continuation not split from item",
input: "- item a\n continuation",
want: "- item a\n continuation",
},
{
name: "text to list transition gets blank line",
input: "paragraph\n- list item",
want: "paragraph\n\n- list item",
},
{
name: "adjacent callout blocks get blank line between them",
input: "<callout>\ncontent1\n</callout>\n<callout>\ncontent2\n</callout>",
want: "<callout>\n\ncontent1\n</callout>\n\n<callout>\n\ncontent2\n</callout>",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixTopLevelSoftbreaks(tt.input)
if got != tt.want {
t.Errorf("fixTopLevelSoftbreaks(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestNormalizeNestedListIndentation(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "nested ordered list uses tabs instead of space pairs",
input: "1. parent\n 1. child\n 1. grandchild",
want: "1. parent\n\t1. child\n\t\t1. grandchild",
},
{
name: "nested mixed list markers use tabs instead of space pairs",
input: "- parent\n - child\n 1. grandchild",
want: "- parent\n\t- child\n\t\t1. grandchild",
},
{
name: "top-level list unchanged",
input: "1. parent\n2. sibling",
want: "1. parent\n2. sibling",
},
{
name: "indented top-level marker without parent list stays unchanged",
input: "paragraph\n\n 1. item",
want: "paragraph\n\n 1. item",
},
{
name: "blank-line-separated loose-list sibling stays unchanged",
input: "1. a\n\n 1. b",
want: "1. a\n\n 1. b",
},
{
name: "indented code block inside list item stays unchanged",
input: "- parent\n\n 1. code",
want: "- parent\n\n 1. code",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeNestedListIndentation(tt.input)
if got != tt.want {
t.Errorf("normalizeNestedListIndentation(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFixExportedMarkdown(t *testing.T) {
// End-to-end: all fixes applied together
input := "# **Title**\nparagraph one\nparagraph two\n**bold **\n> q1\n> q2\nsome text\n---"
result := fixExportedMarkdown(input)
if strings.Contains(result, "# **Title**") {
t.Error("expected heading bold to be stripped")
}
if !strings.Contains(result, "paragraph one\n\nparagraph two") {
t.Error("expected blank line between top-level paragraphs")
}
if strings.Contains(result, "**bold **") {
t.Error("expected trailing space in bold to be fixed")
}
if !strings.Contains(result, ">\n> q2") {
t.Error("expected blockquote hard break inserted")
}
if strings.Contains(result, "some text\n---") {
t.Error("expected blank line before --- to prevent setext heading")
}
// Should end with exactly one newline
if !strings.HasSuffix(result, "\n") || strings.HasSuffix(result, "\n\n") {
t.Errorf("expected result to end with exactly one newline, got %q", result[len(result)-5:])
}
// No triple newlines
if strings.Contains(result, "\n\n\n") {
t.Error("expected no triple newlines in output")
}
}
func TestWarnCalloutType(t *testing.T) {
tests := []struct {
name string
input string
wantHint bool // whether a hint line is expected
hintContains string // substring the hint must contain
}{
{
name: "warning type without background-color emits hint",
input: `<callout type="warning" emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "info type without background-color emits hint",
input: `<callout type="info" emoji="">`,
wantHint: true,
hintContains: `background-color="light-blue"`,
},
{
name: "single-quoted type attribute emits hint",
input: `<callout type='warning' emoji="📝">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
name: "explicit background-color suppresses hint",
input: `<callout type="warning" emoji="📝" background-color="light-red">`,
wantHint: false,
},
{
name: "whitespace around equals is tolerated in background-color",
input: `<callout type="warning" emoji="📝" background-color = "light-red">`,
wantHint: false,
},
{
name: "unknown type emits no hint",
input: `<callout type="custom" emoji="🔥">`,
wantHint: false,
},
{
name: "no type attribute emits no hint",
input: `<callout emoji="💡" background-color="light-green">`,
wantHint: false,
},
{
name: "non-callout tag emits no hint",
input: `<div type="warning">`,
wantHint: false,
},
{
name: "hint includes border-color suggestion",
input: `<callout type="error" emoji="❌">`,
wantHint: true,
hintContains: `border-color="red"`,
},
{
// Regression: the old `\btype=` regex matched the suffix of
// `data-type=` because `-` is a non-word character, so a tag
// carrying only data-attrs would silently get a bogus hint.
// The (?:^|\s) anchor requires a real attribute separator.
name: "data-type attribute does not trigger hint",
input: `<callout data-type="warning" emoji="📝">`,
wantHint: false,
},
{
// Symmetric guard for the background-color regex: a future
// `data-background-color=` attribute must not be mistaken
// for a present background-color and silently suppress the
// hint that the real type= would otherwise produce.
name: "data-background-color does not suppress hint",
input: `<callout type="warning" data-background-color="anything">`,
wantHint: true,
hintContains: `background-color="light-yellow"`,
},
{
// Regression for the code-fence skip: a documentation sample
// inside a ``` fence is NOT a real callout the user wants
// rendered, so it must produce no stderr noise.
name: "callout inside backtick fence emits no hint",
input: "```markdown\n" +
`<callout type="warning" emoji="📝">` + "\n" +
"```\n",
wantHint: false,
},
{
// Same skip works for tilde fences (CommonMark §4.5 makes
// `~~~` an equivalent fence character).
name: "callout inside tilde fence emits no hint",
input: "~~~markdown\n" +
`<callout type="info" emoji="">` + "\n" +
"~~~\n",
wantHint: false,
},
{
// Closing the fence must restore normal scanning: a real
// callout that follows a documentation block still gets a
// hint. Pins that fenceMarker is reset, not stuck.
name: "callout after fence close still emits hint",
input: "```markdown\n" +
`<callout type="warning">sample</callout>` + "\n" +
"```\n" +
`<callout type="error" emoji="❌">real</callout>` + "\n",
wantHint: true,
hintContains: `border-color="red"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
WarnCalloutType(tt.input, &buf)
got := buf.String()
if tt.wantHint {
if got == "" {
t.Errorf("WarnCalloutType(%q): expected hint, got no output", tt.input)
return
}
if tt.hintContains != "" && !strings.Contains(got, tt.hintContains) {
t.Errorf("WarnCalloutType(%q): hint %q missing %q", tt.input, got, tt.hintContains)
}
} else {
if got != "" {
t.Errorf("WarnCalloutType(%q): expected no output, got %q", tt.input, got)
}
}
})
}
}
func TestFixCalloutEmoji(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "warning alias replaced",
input: `<callout emoji="warning" background-color="light-orange">`,
want: `<callout emoji="⚠️" background-color="light-orange">`,
},
{
name: "tip alias replaced",
input: `<callout emoji="tip">`,
want: `<callout emoji="💡">`,
},
{
name: "actual emoji unchanged",
input: `<callout emoji="⚠️">`,
want: `<callout emoji="⚠️">`,
},
{
name: "unknown alias unchanged",
input: `<callout emoji="unicorn">`,
want: `<callout emoji="unicorn">`,
},
{
name: "non-callout tag unchanged",
input: `<div emoji="warning">`,
want: `<div emoji="warning">`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := fixCalloutEmoji(tt.input)
if got != tt.want {
t.Errorf("fixCalloutEmoji(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestApplyOutsideCodeFences(t *testing.T) {
// Transforms should not modify content inside fenced code blocks.
input := "```md\n**x **\n> a\n> b\nline\n---\n```"
if got := applyOutsideCodeFences(input, fixBoldSpacing); got != input {
t.Fatalf("fixBoldSpacing (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
}
if got := applyOutsideCodeFences(input, fixSetextAmbiguity); got != input {
t.Fatalf("fixSetextAmbiguity (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
}
if got := applyOutsideCodeFences(input, fixBlockquoteHardBreaks); got != input {
t.Fatalf("fixBlockquoteHardBreaks (via applyOutsideCodeFences) modified fenced code:\ngot %q\nwant %q", got, input)
}
// Content outside the fence should still be transformed.
mixed := "**foo ** before\n```\n**x **\n```\n**bar ** after"
got := applyOutsideCodeFences(mixed, fixBoldSpacing)
if strings.Contains(got, "**foo **") {
t.Errorf("fixBoldSpacing did not fix bold before fence: %q", got)
}
if strings.Contains(got, "**bar **") {
t.Errorf("fixBoldSpacing did not fix bold after fence: %q", got)
}
if !strings.Contains(got, "```\n**x **\n```") {
t.Errorf("fixBoldSpacing modified content inside fence: %q", got)
}
}
func TestFixTopLevelSoftbreaksQuoteContainer(t *testing.T) {
input := "<quote-container>\nline1\nline2\n</quote-container>"
got := fixTopLevelSoftbreaks(input)
// quote-container is a content container: blank lines inserted between inner lines.
want := "<quote-container>\n\nline1\n\nline2\n</quote-container>"
if got != want {
t.Errorf("fixTopLevelSoftbreaks quote-container = %q, want %q", got, want)
}
}

View File

@@ -9,28 +9,44 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const docsServiceHelpDefault = `Document and content operations.`
const docsServiceHelpV2 = `Document and content operations (v2).`
const docsSkillReadCommand = "lark-cli skills read lark-doc"
const docsXMLSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-xml.md"
const docsMDSkillReadCommand = "lark-cli skills read lark-doc references/lark-doc-md.md"
const docsContentSkillHelp = "AI agents MUST read " +
docsXMLSkillReadCommand + " before writing any --content payload; " +
"when using --doc-format markdown, also read " + docsMDSkillReadCommand + ". " +
"Follow the latest rules there, and MUST NOT grep/open local SKILL.md files " +
"to discover this guidance"
var docsVersionSelectionTips = []string{
"Docs v1 is deprecated and will be removed soon. Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
"After confirming lark-doc is v2, follow that skill's examples and use `--api-version v2` with docs +create, docs +fetch, and docs +update.",
}
var docsV2VersionSelectionTips = []string{
"Check the installed lark-doc skill first; if it is not the v2 skill, run `lark-cli update` to upgrade skills.",
}
func docsTipsForVersion(apiVersion string) []string {
if apiVersion == "v2" {
return docsV2VersionSelectionTips
func docsSkillReadCommandForShortcut(shortcut string) string {
switch strings.TrimPrefix(shortcut, "+") {
case "create":
return docsSkillReadCommand + " references/lark-doc-create.md"
case "fetch":
return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update":
return docsSkillReadCommand + " references/lark-doc-update.md"
default:
return docsSkillReadCommand
}
}
func docsHelpCommandForShortcut(shortcut string) string {
switch strings.TrimPrefix(shortcut, "+") {
case "create":
return "lark-cli docs +create --help"
case "fetch":
return "lark-cli docs +fetch --help"
case "update":
return "lark-cli docs +update --help"
default:
return "lark-cli docs --help"
}
return docsVersionSelectionTips
}
// Shortcuts returns all docs shortcuts.
@@ -48,45 +64,32 @@ func Shortcuts() []common.Shortcut {
}
// ConfigureServiceHelp adds docs-specific guidance to the parent `docs` command.
// The shortcut-level help remains compatible with legacy v1 skills; this parent
// help switches docs guidance to match the selected API version.
func ConfigureServiceHelp(cmd *cobra.Command) {
if cmd == nil {
return
}
serviceCmd := cmd
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
if cmd.Flags().Lookup("api-version") == nil {
cmd.Flags().String("api-version", "", "show docs help for API version (v1|v2)")
cmdutil.RegisterFlagCompletion(cmd, "api-version", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"v1", "v2"}, cobra.ShellCompDirectiveNoFileComp
})
}
defaultHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cmd != serviceCmd {
defaultHelp(cmd, args)
return
}
apiVersion, _ := cmd.Flags().GetString("api-version")
previousLong := cmd.Long
if apiVersion == "v2" {
cmd.Long = strings.TrimSpace(docsServiceHelpV2)
} else {
cmd.Long = strings.TrimSpace(docsServiceHelpDefault)
}
defer func() {
cmd.Long = previousLong
}()
defaultHelp(cmd, args)
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range docsTipsForVersion(apiVersion) {
fmt.Fprintf(out, " • %s\n", tip)
}
})
cmd.Long = docsHelpLong(docsServiceHelpDefault, docsSkillReadCommand)
}
func installDocsShortcutHelp(command string) func(*cobra.Command) {
return func(cmd *cobra.Command) {
cmd.Long = docsHelpLong(cmd.Short, docsSkillReadCommandForShortcut(command))
}
}
func docsHelpLong(summary, skillReadCommand string) string {
return strings.TrimSpace(fmt.Sprintf(`%s
Start here (required for AI agents):
%s
AI agents MUST read the matching embedded skill before choosing flags
or running docs commands. Do not skip this step, and do not infer
workflows from --help alone. MUST NOT grep/open local SKILL.md files
to discover this guidance; use %s so content stays version-matched
with this CLI. Skills ship with the CLI and include docs workflows,
selector/block-id usage, XML/Markdown formats, and copy-paste examples.
skills read lark-doc Docs workflow guide
skills read lark-doc <path> Read a referenced docs skill file`, strings.TrimSpace(summary), skillReadCommand, skillReadCommand))
}

103
shortcuts/doc/v2_only.go Normal file
View File

@@ -0,0 +1,103 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
type docsLegacyFlag struct {
Name string
Replacement string
}
func docsAPIVersionCompatFlag() common.Flag {
return common.Flag{
Name: "api-version",
Desc: "deprecated compatibility flag; docs shortcuts always use v2, and both v1/v2 are accepted for rollback-safe skill examples",
Default: "v2",
}
}
func docsCreateLegacyFlags() []docsLegacyFlag {
return []docsLegacyFlag{
{Name: "title", Replacement: "put the title in --content, for example <title>Title</title>"},
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
{Name: "folder-token", Replacement: "use --parent-token"},
{Name: "wiki-node", Replacement: "use --parent-token"},
{Name: "wiki-space", Replacement: "use --parent-position my_library or a concrete parent position"},
}
}
func docsFetchLegacyFlags() []docsLegacyFlag {
return []docsLegacyFlag{
{Name: "offset", Replacement: "use --scope outline/range/keyword/section for partial reads"},
{Name: "limit", Replacement: "use --scope outline/range/keyword/section for partial reads"},
}
}
func docsUpdateLegacyFlags() []docsLegacyFlag {
return []docsLegacyFlag{
{Name: "mode", Replacement: "use --command"},
{Name: "markdown", Replacement: "use --content with --doc-format markdown"},
{Name: "selection-with-ellipsis", Replacement: "use --command str_replace with --pattern"},
{Name: "selection-by-title", Replacement: "fetch block ids first, then use --command block_replace/block_insert_after with --block-id"},
{Name: "new-title", Replacement: "update the title through XML content in --content"},
}
}
func docsLegacyFlagDefinitions(flags []docsLegacyFlag) []common.Flag {
out := make([]common.Flag, 0, len(flags))
for _, flag := range flags {
out = append(out, common.Flag{
Name: flag.Name,
Desc: "deprecated v1 compatibility flag; run `lark-cli skills read lark-doc` for the v2 CLI skill",
Hidden: true,
})
}
return out
}
func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyFlags []docsLegacyFlag) error {
switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion {
case "", "v1", "v2":
default:
return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API")
}
var used []string
var replacements []string
for _, flag := range legacyFlags {
if !runtime.Changed(flag.Name) {
continue
}
used = append(used, "--"+flag.Name)
if flag.Replacement != "" {
replacements = append(replacements, "--"+flag.Name+" -> "+flag.Replacement)
}
}
if len(used) == 0 {
return nil
}
detail := "the old v1 interface has been shut down; legacy v1 flag(s) " + strings.Join(used, ", ") + " are no longer supported"
if len(replacements) > 0 {
detail += "; " + strings.Join(replacements, "; ")
}
return docsV2OnlyError(shortcut, detail)
}
func docsV2OnlyError(shortcut, detail string) error {
return common.FlagErrorf(
"docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags",
shortcut,
detail,
docsSkillReadCommandForShortcut(shortcut),
docsXMLSkillReadCommand,
docsMDSkillReadCommand,
docsHelpCommandForShortcut(shortcut),
)
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestValidateDocsV2OnlyAllowsDefaultAndDeprecatedAPIVersionValues(t *testing.T) {
for _, apiVersion := range []string{"", "v1", "v2"} {
t.Run(apiVersion, func(t *testing.T) {
runtime := docsV2OnlyTestRuntime(t, apiVersion, false)
if err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}}); err != nil {
t.Fatalf("validateDocsV2Only(%q) error = %v, want nil", apiVersion, err)
}
})
}
}
func TestValidateDocsV2OnlyRejectsUnknownAPIVersion(t *testing.T) {
runtime := docsV2OnlyTestRuntime(t, "v0", false)
err := validateDocsV2Only(runtime, "+fetch", nil)
if err == nil {
t.Fatal("expected unknown --api-version to be rejected")
}
for _, want := range []string{
"docs +fetch is v2-only",
"--api-version is deprecated and only accepts v1 or v2",
"both values execute the v2 API",
"lark-cli skills read lark-doc references/lark-doc-fetch.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"MUST NOT grep/open local SKILL.md files",
"lark-cli docs +fetch --help",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
}
func TestValidateDocsV2OnlyRejectsChangedLegacyFlags(t *testing.T) {
runtime := docsV2OnlyTestRuntime(t, "", true)
err := validateDocsV2Only(runtime, "+update", []docsLegacyFlag{{Name: "mode", Replacement: "use --command"}})
if err == nil {
t.Fatal("expected changed legacy flag to be rejected")
}
for _, want := range []string{
"the old v1 interface has been shut down",
"legacy v1 flag(s) --mode are no longer supported",
"--mode -> use --command",
"lark-cli skills read lark-doc references/lark-doc-update.md",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"MUST NOT grep/open local SKILL.md files",
"lark-cli docs +update --help",
} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
}
func docsV2OnlyTestRuntime(t *testing.T, apiVersion string, legacyMode bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "+update"}
cmd.Flags().String("api-version", "", "")
cmd.Flags().String("mode", "", "")
if apiVersion != "" {
if err := cmd.Flags().Set("api-version", apiVersion); err != nil {
t.Fatalf("set api-version: %v", err)
}
}
if legacyMode {
if err := cmd.Flags().Set("mode", "overwrite"); err != nil {
t.Fatalf("set mode: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, nil)
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/shortcuts/common"
)
// installVersionedHelp sets a custom help function on cmd that shows only the
// flags relevant to the selected --api-version. flagVersions maps flag name to
// its version ("v1" or "v2"). Flags not in the map are treated as shared and
// always visible.
func installVersionedHelp(cmd *cobra.Command, defaultVersion string, flagVersions map[string]string) {
origHelp := cmd.HelpFunc()
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
ver, _ := cmd.Flags().GetString("api-version")
if ver == "" {
ver = defaultVersion
}
// Show/hide flags based on the active version.
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if fv, ok := flagVersions[f.Name]; ok {
f.Hidden = fv != ver
}
})
cmdutil.SetTips(cmd, docsTipsForVersion(ver))
origHelp(cmd, args)
})
}
// warnDeprecatedV1 prints a deprecation notice to stderr when the v1 (MCP) code
// path is used.
func warnDeprecatedV1(runtime *common.RuntimeContext, shortcut string) {
fmt.Fprintf(runtime.IO().ErrOut,
"[deprecated] docs %s is using the v1 API. %s\n",
shortcut, docsV2VersionSelectionTips[0])
}

View File

@@ -1,36 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
)
func TestWarnDeprecatedV1SuggestsSkillUpdate(t *testing.T) {
for _, shortcut := range []string{"+create", "+fetch", "+update"} {
t.Run(shortcut, func(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{})
warnDeprecatedV1(&common.RuntimeContext{Factory: f}, shortcut)
got := stderr.String()
for _, want := range []string{
"[deprecated] docs " + shortcut + " is using the v1 API.",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
} {
if !strings.Contains(got, want) {
t.Fatalf("warning missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "will be removed in a future release") {
t.Fatalf("warning should not include removal-only guidance:\n%s", got)
}
})
}
}

View File

@@ -8,11 +8,19 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveInspectRateLimitRetries = 2
driveInspectRetryInitialBackoff = 200 * time.Millisecond
)
var driveInspectAfter = time.After
var DriveInspect = common.Shortcut{
Service: "drive",
Command: "+inspect",
@@ -35,32 +43,15 @@ var DriveInspect = common.Shortcut{
},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
}
_, ok := common.ParseResourceURL(raw)
if !ok {
// Not a recognized URL pattern.
if strings.Contains(raw, "://") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
}
// Bare token: --type is required.
if strings.TrimSpace(runtime.Str("type")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
}
if _, err := driveInspectResolveRef(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
raw := strings.TrimSpace(runtime.Str("url"))
ref, ok := common.ParseResourceURL(raw)
if !ok {
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
ref, err := driveInspectResolveRef(runtime)
if err != nil {
return common.NewDryRunAPI()
}
dry := common.NewDryRunAPI()
@@ -91,15 +82,9 @@ var DriveInspect = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
// Step 1: Parse URL to extract {type, token}.
ref, ok := common.ParseResourceURL(raw)
if !ok {
// Bare token: use --type.
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
ref, err := driveInspectResolveRef(runtime)
if err != nil {
return err
}
inputURL := raw
@@ -111,14 +96,19 @@ var DriveInspect = common.Shortcut{
// Step 2: If type is "wiki", unwrap via get_node API.
if docType == "wiki" {
fmt.Fprintf(runtime.IO().ErrOut, "Inspecting wiki node: %s\n", common.MaskToken(docToken))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
data, err := driveInspectCallWithRetry(
ctx,
func() (map[string]interface{}, error) {
return runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
)
},
)
if err != nil {
return err
return driveInspectAnnotateError("resolve_wiki", err)
}
node := common.GetMap(data, "node")
@@ -145,9 +135,9 @@ var DriveInspect = common.Shortcut{
}
// Step 3: Call batch_query to verify and get title.
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
title, err := driveInspectFetchMetaTitle(ctx, runtime, docToken, docType)
if err != nil {
return err
return driveInspectAnnotateError("query_meta", err)
}
// Step 4: Build the resolved URL.
@@ -181,3 +171,116 @@ var DriveInspect = common.Shortcut{
return nil
},
}
func driveInspectResolveRef(runtime *common.RuntimeContext) (common.ResourceRef, error) {
raw := strings.TrimSpace(runtime.Str("url"))
if raw == "" {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url cannot be empty").WithParam("--url")
}
inputType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
ref, ok := common.ParseResourceURL(raw)
if ok {
if inputType != "" && inputType != ref.Type {
return common.ResourceRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
ref.Type,
).WithParam("--type")
}
return ref, nil
}
if strings.Contains(raw, "://") {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --url %q: use a recognized Lark document URL or a bare token with --type", raw).WithParam("--url")
}
if strings.ContainsAny(raw, "/?#") {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments and pass only the raw token with --type", raw).WithParam("--url")
}
if inputType == "" {
return common.ResourceRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --url is a bare token (allowed: doc, docx, sheet, bitable, wiki, file, folder, mindnote, slides)").WithParam("--type")
}
return common.ResourceRef{Type: inputType, Token: raw}, nil
}
func driveInspectFetchMetaTitle(ctx context.Context, runtime *common.RuntimeContext, token, docType string) (string, error) {
var title string
_, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
got, callErr := common.FetchDriveMeta(runtime, token, docType, false)
if callErr != nil {
return nil, callErr
}
title = got.Title
return map[string]interface{}{"title": got.Title}, nil
})
if err != nil {
return "", err
}
return title, nil
}
func driveInspectCallWithRetry(ctx context.Context, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
var lastErr error
for attempt := 0; attempt <= driveInspectRateLimitRetries; attempt++ {
data, err := call()
if err == nil {
return data, nil
}
lastErr = err
if !driveInspectShouldRetry(err) || attempt == driveInspectRateLimitRetries {
return nil, err
}
backoff := driveInspectRetryInitialBackoff * time.Duration(1<<attempt)
if waitErr := driveInspectWait(ctx, backoff); waitErr != nil {
return nil, waitErr
}
}
return nil, lastErr
}
func driveInspectShouldRetry(err error) bool {
problem, ok := errs.ProblemOf(err)
if !ok || problem == nil {
return false
}
return problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400 || problem.Retryable
}
func driveInspectWait(ctx context.Context, d time.Duration) error {
if d <= 0 {
return nil
}
select {
case <-ctx.Done():
return errs.WrapInternal(ctx.Err())
case <-driveInspectAfter(d):
return nil
}
}
func driveInspectAnnotateError(stage string, err error) error {
problem, ok := errs.ProblemOf(err)
if !ok || problem == nil {
return err
}
label := map[string]string{
"resolve_wiki": "resolve wiki node",
"query_meta": "query document metadata",
}[stage]
if label == "" {
label = stage
}
problem.Message = fmt.Sprintf("%s failed: %s", label, problem.Message)
if strings.TrimSpace(problem.Hint) == "" {
switch stage {
case "resolve_wiki":
problem.Hint = "check that the wiki URL/token is valid and that the current identity can read the wiki node"
case "query_meta":
problem.Hint = "check that the resolved document still exists and that the current identity can read its metadata"
}
} else if !strings.Contains(problem.Hint, label) {
problem.Hint = label + ": " + problem.Hint
}
return err
}

View File

@@ -6,10 +6,13 @@ package drive
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -83,6 +86,34 @@ func TestDriveInspectValidate_BareTokenWithType(t *testing.T) {
}
}
func TestDriveInspectValidate_URLTypeConflict(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "https://xxx.feishu.cn/docx/doxcnBareToken")
_ = cmd.Flags().Set("type", "sheet")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for conflicting --type, got nil")
}
}
func TestDriveInspectValidate_BareTokenWithPathFragment(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("type", "", "")
_ = cmd.Flags().Set("url", "doxcnBareToken/extra")
_ = cmd.Flags().Set("type", "docx")
runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{})
err := DriveInspect.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected error for bare token with path fragment, got nil")
}
}
func TestDriveInspectValidate_ValidDocxURL(t *testing.T) {
cmd := &cobra.Command{Use: "drive +inspect"}
cmd.Flags().String("url", "", "")
@@ -540,6 +571,76 @@ func TestDriveInspectExecute_BatchQueryError(t *testing.T) {
if err == nil {
t.Fatal("expected error for batch_query failure, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if !strings.Contains(p.Message, "query document metadata failed") {
t.Fatalf("message = %q, want query document metadata prefix", p.Message)
}
}
func TestDriveInspectExecute_RetriesRateLimitOnWikiResolve(t *testing.T) {
cfg := driveTestConfig()
f, stdout, _, reg := cmdutil.TestFactory(t, cfg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 99991400,
"msg": "request trigger frequency limit",
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "doxcnUnwrapped",
"space_id": "space123",
"node_token": "wikcnNodeToken",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "doxcnUnwrapped", "doc_type": "docx", "title": "Wiki Doc"},
},
},
},
})
origAfter := driveInspectAfter
driveInspectAfter = func(time.Duration) <-chan time.Time {
ch := make(chan time.Time, 1)
ch <- time.Now()
return ch
}
defer func() { driveInspectAfter = origAfter }()
err := mountAndRunDrive(t, DriveInspect, []string{
"+inspect",
"--url", "https://xxx.feishu.cn/wiki/wikcnABC",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error after retry: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["token"] != "doxcnUnwrapped" {
t.Fatalf("token = %v, want doxcnUnwrapped", data["token"])
}
}
func TestDriveInspectExecute_PrettyFormat(t *testing.T) {

View File

@@ -609,6 +609,9 @@ func TestShortcuts(t *testing.T) {
"+feed-shortcut-create",
"+feed-shortcut-remove",
"+feed-shortcut-list",
"+feed-group-list",
"+feed-group-list-item",
"+feed-group-query-item",
}
if !reflect.DeepEqual(commands, want) {
t.Fatalf("Shortcuts() commands = %#v, want %#v", commands, want)

View File

@@ -0,0 +1,713 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// recordedFGRequest captures one outbound request for assertion.
type recordedFGRequest struct {
method string
path string
query map[string][]string
body map[string]interface{}
}
// fgResponder maps a URL path suffix to a JSON response body.
type fgResponder func(path string, page int) (int, interface{})
// newFGCmd builds a cobra command carrying the shortcut's flags, applying the
// provided overrides.
func newFGCmd(t *testing.T, sc common.Shortcut, flags map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: sc.Command}
for _, fl := range sc.Flags {
switch fl.Type {
case "bool":
cmd.Flags().Bool(fl.Name, fl.Default == "true", fl.Desc)
case "int":
def := 0
if fl.Default != "" {
n, _ := strconv.Atoi(fl.Default)
def = n
}
cmd.Flags().Int(fl.Name, def, fl.Desc)
default:
cmd.Flags().String(fl.Name, fl.Default, fl.Desc)
}
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range flags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("set flag %s=%s: %v", name, val, err)
}
}
return cmd
}
// newFGRuntime wires a user-identity runtime with the shortcut's flags and an
// httpmock transport that records requests and replies via the responder.
func newFGRuntime(t *testing.T, sc common.Shortcut, flags map[string]string, recorded *[]recordedFGRequest, responder fgResponder) *common.RuntimeContext {
t.Helper()
pageByPath := map[string]int{}
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
rec := recordedFGRequest{
method: req.Method,
path: req.URL.Path,
query: req.URL.Query(),
}
if req.Body != nil {
data, _ := io.ReadAll(req.Body)
if len(data) > 0 {
_ = json.Unmarshal(data, &rec.body)
}
}
if recorded != nil {
*recorded = append(*recorded, rec)
}
pageByPath[req.URL.Path]++
status, body := 200, interface{}(map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
if responder != nil {
status, body = responder(req.URL.Path, pageByPath[req.URL.Path])
}
return shortcutJSONResponse(status, body), nil
})
runtime := newUserShortcutRuntime(t, rt)
runtime.Cmd = newFGCmd(t, sc, flags)
runtime.Format = "json"
return runtime
}
func wrapData(d map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"code": 0, "data": d}
}
func findFGRequest(reqs []recordedFGRequest, pathSuffix string) *recordedFGRequest {
for i := range reqs {
if strings.HasSuffix(reqs[i].path, pathSuffix) {
return &reqs[i]
}
}
return nil
}
func firstQueryValue(q map[string][]string, key string) string {
if v := q[key]; len(v) > 0 {
return v[0]
}
return ""
}
// dryRunJSON marshals a DryRunAPI to its wire shape so tests can assert against
// the public JSON (calls/extra are unexported on the struct).
func dryRunJSON(t *testing.T, d *common.DryRunAPI) map[string]interface{} {
t.Helper()
b, err := json.Marshal(d)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal dry-run: %v", err)
}
return m
}
func dryRunCalls(t *testing.T, d *common.DryRunAPI) []map[string]interface{} {
t.Helper()
m := dryRunJSON(t, d)
raw, _ := m["api"].([]interface{})
calls := make([]map[string]interface{}, 0, len(raw))
for _, c := range raw {
cm, _ := c.(map[string]interface{})
calls = append(calls, cm)
}
return calls
}
func countFGRequests(reqs []recordedFGRequest, pathSuffix string) int {
n := 0
for i := range reqs {
if strings.HasSuffix(reqs[i].path, pathSuffix) {
n++
}
}
return n
}
// ── list-item: happy path with enrichment of items + deleted_items ──
func TestFeedGroupListItemEnrichesBothLists(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs,
func(path string, _ int) (int, interface{}) {
switch {
case strings.HasSuffix(path, "/list_item"):
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}},
"deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}},
"page_token": "",
"has_more": false,
})
case strings.HasSuffix(path, "/chats/batch_query"):
return 200, wrapData(map[string]interface{}{"items": []interface{}{
map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"},
map[string]interface{}{"chat_id": "oc_def", "name": "Old Channel"},
}})
}
return 200, wrapData(map[string]interface{}{})
})
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute returned error: %v", err)
}
list := findFGRequest(reqs, "/list_item")
if list == nil {
t.Fatal("expected list_item request")
}
if list.method != http.MethodGet {
t.Errorf("list_item method = %s, want GET", list.method)
}
if !strings.HasSuffix(list.path, "/open-apis/im/v1/groups/ofg_x/list_item") {
t.Errorf("list_item path = %s", list.path)
}
if findFGRequest(reqs, "/chats/batch_query") == nil {
t.Error("expected chats/batch_query enrichment request")
}
}
// ── list-item: empty items skips enrichment ──
func TestFeedGroupListItemEmptySkipsEnrichment(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, &reqs,
func(path string, _ int) (int, interface{}) {
if strings.HasSuffix(path, "/list_item") {
return 200, wrapData(map[string]interface{}{
"items": []interface{}{}, "deleted_items": []interface{}{},
"page_token": "", "has_more": false,
})
}
return 200, wrapData(map[string]interface{}{})
})
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
if findFGRequest(reqs, "/chats/batch_query") != nil {
t.Error("did not expect batch_query when there are no items")
}
}
// ── list-item: page-all merges across 2 pages, empty deleted serializes as [] ──
func TestFeedGroupListItemPageAllMerges(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-all": "true"}, &reqs,
func(path string, page int) (int, interface{}) {
if strings.HasSuffix(path, "/list_item") {
if page == 1 {
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}},
"deleted_items": []interface{}{},
"page_token": "TKN", "has_more": true,
})
}
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_b", "feed_type": "chat", "update_time": "2"}},
"deleted_items": []interface{}{},
"page_token": "", "has_more": false,
})
}
if strings.HasSuffix(path, "/chats/batch_query") {
return 200, wrapData(map[string]interface{}{"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "name": "A"},
map[string]interface{}{"chat_id": "oc_b", "name": "B"},
}})
}
return 200, wrapData(map[string]interface{}{})
})
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
if got := countFGRequests(reqs, "/list_item"); got != 2 {
t.Errorf("expected 2 list_item requests, got %d", got)
}
// Second list_item page must carry the continuation token.
var second *recordedFGRequest
n := 0
for i := range reqs {
if strings.HasSuffix(reqs[i].path, "/list_item") {
n++
if n == 2 {
second = &reqs[i]
}
}
}
if second == nil || firstQueryValue(second.query, "page_token") != "TKN" {
t.Errorf("second page token = %q, want TKN", firstQueryValue(second.query, "page_token"))
}
}
// ── list-item: explicit page-token ignores page-all (single page) ──
func TestFeedGroupListItemPageTokenIgnoresPageAll(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
"feed-group-id": "ofg_x", "page-all": "true", "page-token": "SOMETOKEN",
}, &reqs, func(path string, _ int) (int, interface{}) {
if strings.HasSuffix(path, "/list_item") {
return 200, wrapData(map[string]interface{}{
"items": []interface{}{}, "deleted_items": []interface{}{},
"page_token": "NEXT", "has_more": true,
})
}
return 200, wrapData(map[string]interface{}{})
})
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
if got := countFGRequests(reqs, "/list_item"); got != 1 {
t.Errorf("expected 1 list_item request (page-token wins), got %d", got)
}
req := findFGRequest(reqs, "/list_item")
if got := firstQueryValue(req.query, "page_token"); got != "SOMETOKEN" {
t.Errorf("page_token query = %q, want SOMETOKEN", got)
}
}
// ── query-item: builds correct body and enriches ──
func TestFeedGroupQueryItemBuildsBody(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
"feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b",
}, &reqs, func(path string, _ int) (int, interface{}) {
switch {
case strings.HasSuffix(path, "/batch_query_item"):
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}},
"deleted_items": []interface{}{},
})
case strings.HasSuffix(path, "/chats/batch_query"):
return 200, wrapData(map[string]interface{}{"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "name": "Team A"},
}})
}
return 200, wrapData(map[string]interface{}{})
})
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
req := findFGRequest(reqs, "/batch_query_item")
if req == nil {
t.Fatal("expected batch_query_item request")
}
if req.method != http.MethodPost {
t.Errorf("method = %s, want POST", req.method)
}
if !strings.HasSuffix(req.path, "/open-apis/im/v1/groups/ofg_x/batch_query_item") {
t.Errorf("path = %s", req.path)
}
items, ok := req.body["items"].([]interface{})
if !ok || len(items) != 2 {
t.Fatalf("body items = %#v, want 2 entries", req.body["items"])
}
first, _ := items[0].(map[string]interface{})
if first["feed_id"] != "oc_a" || first["feed_type"] != "chat" {
t.Errorf("first item = %#v", first)
}
}
// ── table output: renders feed_id / chat_name / update_time + summary lines ──
func TestFeedGroupListItemTableOutput(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x"}, nil,
func(path string, _ int) (int, interface{}) {
switch {
case strings.HasSuffix(path, "/list_item"):
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_abc", "feed_type": "chat", "update_time": "1767196800000"}},
"deleted_items": []interface{}{map[string]interface{}{"feed_id": "oc_def", "feed_type": "chat", "update_time": "1767196800000"}},
"page_token": "TKN", "has_more": true,
})
case strings.HasSuffix(path, "/chats/batch_query"):
return 200, wrapData(map[string]interface{}{"items": []interface{}{
map[string]interface{}{"chat_id": "oc_abc", "name": "Release Team"},
}})
}
return 200, wrapData(map[string]interface{}{})
})
runtime.Format = "pretty"
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if out == nil {
t.Fatal("stdout buffer missing")
}
got := out.String()
for _, want := range []string{"feed_id", "chat_name", "update_time", "oc_abc", "Release Team", "1 item(s)", "more available", "(1 deleted)"} {
if !strings.Contains(got, want) {
t.Errorf("table output missing %q; got:\n%s", want, got)
}
}
// update_time must be rendered human-readable (RFC3339), not as raw Unix millis.
if strings.Contains(got, "1767196800000") {
t.Errorf("table output should not contain raw millis timestamp; got:\n%s", got)
}
wantTime := time.UnixMilli(1767196800000).Local().Format(time.RFC3339)
if !strings.Contains(got, wantTime) {
t.Errorf("table output should contain formatted update_time %q; got:\n%s", wantTime, got)
}
}
// ── enrichment graceful degradation: unresolved feed_id keeps no chat_name ──
func TestEnrichFeedGroupItemsGracefulDegradation(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
"feed-group-id": "ofg_x", "feed-id": "oc_known",
}, nil, func(path string, _ int) (int, interface{}) {
if strings.HasSuffix(path, "/chats/batch_query") {
// Only oc_known resolves; oc_gone is absent.
return 200, wrapData(map[string]interface{}{"items": []interface{}{
map[string]interface{}{"chat_id": "oc_known", "name": "Known"},
}})
}
return 200, wrapData(map[string]interface{}{})
})
data := map[string]any{
"items": []any{
map[string]any{"feed_id": "oc_known", "feed_type": "chat"},
map[string]any{"feed_id": "oc_gone", "feed_type": "chat"},
},
"deleted_items": []any{},
}
enrichFeedGroupItemsChatName(runtime, data)
items := data["items"].([]any)
known := items[0].(map[string]any)
gone := items[1].(map[string]any)
if known["chat_name"] != "Known" {
t.Errorf("oc_known chat_name = %v, want Known", known["chat_name"])
}
if _, present := gone["chat_name"]; present {
t.Errorf("oc_gone should not have chat_name, got %v", gone["chat_name"])
}
}
// ── validation errors ──
func TestFeedGroupValidationErrors(t *testing.T) {
cases := []struct {
name string
sc common.Shortcut
flags map[string]string
want string
}{
{"list missing feed-group-id", ImFeedGroupListItem, map[string]string{}, "--feed-group-id is required"},
{"list bad page-size", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-size": "0"}, "--page-size must be an integer between 1 and 50"},
{"list bad page-limit", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "page-limit": "2000"}, "--page-limit must be an integer between 1 and 1000"},
{"list bad start-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "start-time": "notnum"}, "--start-time must be Unix milliseconds"},
{"list bad end-time", ImFeedGroupListItem, map[string]string{"feed-group-id": "ofg_x", "end-time": "notnum"}, "--end-time must be Unix milliseconds"},
{"query missing feed-group-id", ImFeedGroupQueryItem, map[string]string{"feed-id": "oc_a"}, "--feed-group-id is required"},
{"query missing feed-id", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, "--feed-id is required (comma-separated chat IDs)"},
{"query blank feed-id tokens", ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x", "feed-id": ", ,"}, "--feed-id is required (comma-separated chat IDs)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runtime := newFGRuntime(t, tc.sc, tc.flags, nil, nil)
err := tc.sc.Validate(context.Background(), runtime)
if err == nil {
t.Fatalf("expected validation error %q, got nil", tc.want)
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("error = %q, want contains %q", err.Error(), tc.want)
}
})
}
}
// ── dry-run shapes ──
func TestFeedGroupListItemDryRun(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
"feed-group-id": "ofg_x", "page-size": "10", "page-token": "TKN", "start-time": "100", "end-time": "200",
}, nil, nil)
d := ImFeedGroupListItem.DryRun(context.Background(), runtime)
calls := dryRunCalls(t, d)
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0]["method"] != "GET" {
t.Errorf("method = %v, want GET", calls[0]["method"])
}
if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/list_item") {
t.Errorf("url = %s", url)
}
params, _ := calls[0]["params"].(map[string]interface{})
for key, want := range map[string]string{
"page_size": "10", "page_token": "TKN", "start_time": "100", "end_time": "200",
} {
if params[key] != want {
t.Errorf("params %s = %v, want %s", key, params[key], want)
}
}
if desc, _ := calls[0]["desc"].(string); !strings.Contains(desc, "im:chat:read") {
t.Errorf("desc = %q, want chat_name enrichment note", desc)
}
}
func TestFeedGroupListItemDryRunValidationError(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{}, nil, nil)
d := ImFeedGroupListItem.DryRun(context.Background(), runtime)
m := dryRunJSON(t, d)
errMsg, _ := m["error"].(string)
if errMsg == "" {
t.Fatalf("expected error in dry-run output, got %#v", m)
}
if !strings.Contains(errMsg, "--feed-group-id is required") {
t.Errorf("error = %v", errMsg)
}
}
func TestFeedGroupQueryItemDryRun(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
"feed-group-id": "ofg_x", "feed-id": "oc_a,oc_b",
}, nil, nil)
d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime)
calls := dryRunCalls(t, d)
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0]["method"] != "POST" {
t.Errorf("method = %v, want POST", calls[0]["method"])
}
if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/groups/ofg_x/batch_query_item") {
t.Errorf("url = %s", url)
}
body, _ := calls[0]["body"].(map[string]interface{})
items, _ := body["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("dry-run body items = %#v, want 2", body["items"])
}
}
func TestFeedGroupQueryItemDryRunValidationError(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, nil, nil)
d := ImFeedGroupQueryItem.DryRun(context.Background(), runtime)
m := dryRunJSON(t, d)
if errMsg, _ := m["error"].(string); errMsg == "" {
t.Fatalf("expected error in dry-run output, got %#v", m)
}
}
// ── list-item: time-window flags reach the query ──
func TestFeedGroupListItemTimeWindowQueryParams(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
"feed-group-id": "ofg_x", "start-time": "100", "end-time": "200",
}, &reqs, func(path string, _ int) (int, interface{}) {
if strings.HasSuffix(path, "/list_item") {
return 200, wrapData(map[string]interface{}{
"items": []interface{}{}, "deleted_items": []interface{}{},
"page_token": "", "has_more": false,
})
}
return 200, wrapData(map[string]interface{}{})
})
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
req := findFGRequest(reqs, "/list_item")
if req == nil {
t.Fatal("expected list_item request")
}
if got := firstQueryValue(req.query, "start_time"); got != "100" {
t.Errorf("start_time query = %q, want 100", got)
}
if got := firstQueryValue(req.query, "end_time"); got != "200" {
t.Errorf("end_time query = %q, want 200", got)
}
}
// ── list-item: infinite-loop guard + defensive page-limit clamping ──
func TestFeedGroupListItemPageAllStopsOnRepeatedToken(t *testing.T) {
for _, tc := range []struct {
name string
pageLimit string
}{
{"limit clamped up from 0", "0"},
{"limit clamped down from 1001", "1001"},
} {
t.Run(tc.name, func(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
"feed-group-id": "ofg_x", "page-all": "true", "page-limit": tc.pageLimit,
"start-time": "100", "end-time": "200",
}, &reqs, func(path string, _ int) (int, interface{}) {
if strings.HasSuffix(path, "/list_item") {
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1"}},
"deleted_items": []interface{}{},
"page_token": "SAME", "has_more": true,
})
}
return 200, wrapData(map[string]interface{}{})
})
runtime.Format = "pretty" // exercise the page-all table-render path too
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
if got := countFGRequests(reqs, "/list_item"); got != 2 {
t.Errorf("expected 2 list_item requests (stop on repeated token), got %d", got)
}
errOut, _ := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "page_token did not change") {
t.Errorf("stderr missing loop warning; got:\n%s", errOut.String())
}
})
}
}
// ── list-item: API errors surface from both Execute paths ──
func TestFeedGroupListItemAPIError(t *testing.T) {
for _, tc := range []struct {
name string
flags map[string]string
}{
{"single page", map[string]string{"feed-group-id": "ofg_x"}},
{"page-all", map[string]string{"feed-group-id": "ofg_x", "page-all": "true"}},
} {
t.Run(tc.name, func(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupListItem, tc.flags, nil,
func(_ string, _ int) (int, interface{}) {
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
})
if err := ImFeedGroupListItem.Execute(context.Background(), runtime); err == nil {
t.Fatal("expected API error, got nil")
}
})
}
}
// ── enrichment: total resolution failure warns on stderr, nil data is a no-op ──
func TestEnrichFeedGroupItemsWarnsWhenResolutionFails(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{}, nil,
func(path string, _ int) (int, interface{}) {
if strings.HasSuffix(path, "/chats/batch_query") {
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
}
return 200, wrapData(map[string]interface{}{})
})
// nil data must not panic.
enrichFeedGroupItemsChatName(runtime, nil)
data := map[string]any{
"items": []any{map[string]any{"feed_id": "oc_a", "feed_type": "chat"}},
"deleted_items": []any{},
}
enrichFeedGroupItemsChatName(runtime, data)
item := data["items"].([]any)[0].(map[string]any)
if _, present := item["chat_name"]; present {
t.Errorf("chat_name should be absent when resolution fails, got %v", item["chat_name"])
}
errOut, _ := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "could not resolve chat names") {
t.Errorf("stderr missing resolution warning; got:\n%s", errOut.String())
}
}
// ── query-item: Execute error paths ──
func TestFeedGroupQueryItemExecuteErrors(t *testing.T) {
t.Run("invalid flags", func(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{"feed-group-id": "ofg_x"}, nil, nil)
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err == nil {
t.Fatal("expected validation error from Execute, got nil")
}
})
t.Run("api error", func(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
"feed-group-id": "ofg_x", "feed-id": "oc_a",
}, nil, func(_ string, _ int) (int, interface{}) {
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
})
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err == nil {
t.Fatal("expected API error, got nil")
}
})
}
// ── formatFeedGroupUpdateTime: empty / non-numeric inputs pass through ──
func TestFormatFeedGroupUpdateTime(t *testing.T) {
if got := formatFeedGroupUpdateTime(""); got != "" {
t.Errorf("empty input = %q, want empty passthrough", got)
}
if got := formatFeedGroupUpdateTime("not-millis"); got != "not-millis" {
t.Errorf("non-numeric input = %q, want raw passthrough", got)
}
want := time.UnixMilli(1767196800000).Local().Format(time.RFC3339)
if got := formatFeedGroupUpdateTime("1767196800000"); got != want {
t.Errorf("millis input = %q, want %q", got, want)
}
}
// ── query-item: pretty table output renders enriched items ──
func TestFeedGroupQueryItemTableOutput(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupQueryItem, map[string]string{
"feed-group-id": "ofg_x", "feed-id": "oc_a",
}, nil, func(path string, _ int) (int, interface{}) {
switch {
case strings.HasSuffix(path, "/batch_query_item"):
return 200, wrapData(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"feed_id": "oc_a", "feed_type": "chat", "update_time": "1767196800000"}},
"deleted_items": []interface{}{},
})
case strings.HasSuffix(path, "/chats/batch_query"):
return 200, wrapData(map[string]interface{}{"items": []interface{}{
map[string]interface{}{"chat_id": "oc_a", "name": "Team A"},
}})
}
return 200, wrapData(map[string]interface{}{})
})
runtime.Format = "pretty"
if err := ImFeedGroupQueryItem.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute error: %v", err)
}
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if out == nil {
t.Fatal("stdout buffer missing")
}
got := out.String()
for _, want := range []string{"oc_a", "Team A", "1 item(s)"} {
if !strings.Contains(got, want) {
t.Errorf("table output missing %q; got:\n%s", want, got)
}
}
}

View File

@@ -0,0 +1,140 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"fmt"
"io"
"strconv"
"time"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// feedGroupReadScope is required to read feed-group items.
feedGroupReadScope = "im:feed_group_v1:read"
// chatReadScope is required to resolve chat_name from feed_id via chats/batch_query.
chatReadScope = "im:chat:read"
)
// enrichFeedGroupItemsChatName resolves a human-readable chat_name for each feed
// card in data["items"] and data["deleted_items"] using chats/batch_query.
//
// The feed_id of a v1 feed card is always a chat ID (oc_xxx), so the chat's name
// is the natural display label. Resolution degrades gracefully: any feed_id that
// cannot be resolved simply keeps no chat_name key, and the function never returns
// an error or alters the exit code.
//
// NOTE: This mutates the item maps in place by adding a "chat_name" key.
func enrichFeedGroupItemsChatName(rt *common.RuntimeContext, data map[string]any) {
if data == nil {
return
}
items, _ := data["items"].([]any)
deletedItems, _ := data["deleted_items"].([]any)
// Collect deduped, ordered feed_id strings from both lists.
ids := make([]string, 0, len(items)+len(deletedItems))
seen := make(map[string]bool)
collect := func(list []any) {
for _, it := range list {
m, _ := it.(map[string]any)
if m == nil {
continue
}
id, _ := m["feed_id"].(string)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
}
collect(items)
collect(deletedItems)
if len(ids) == 0 {
return
}
contexts := batchQueryChatContexts(rt, ids)
if len(contexts) == 0 {
// We had feed_ids to resolve but got nothing back — most likely the
// chats/batch_query call failed (it degrades silently). Tell the user so
// an empty chat_name column is not mistaken for chats that simply have no name.
fmt.Fprintf(rt.IO().ErrOut, "warning: could not resolve chat names for %d feed(s); chat_name will be empty\n", len(ids))
return
}
apply := func(list []any) {
for _, it := range list {
m, _ := it.(map[string]any)
if m == nil {
continue
}
id, _ := m["feed_id"].(string)
if id == "" {
continue
}
if ctx, ok := contexts[id]; ok {
if name, _ := ctx["name"].(string); name != "" {
m["chat_name"] = name
}
}
}
}
apply(items)
apply(deletedItems)
}
// renderFeedGroupItemsTable prints the active items[] as a table (feed_id /
// chat_name / update_time), followed by a summary line. When hasMore is true a
// pagination hint is appended; when there are deleted items their count is noted.
func renderFeedGroupItemsTable(w io.Writer, data map[string]any, hasMore bool) {
items, _ := data["items"].([]any)
rows := make([]map[string]interface{}, 0, len(items))
for _, it := range items {
m, _ := it.(map[string]any)
if m == nil {
continue
}
chatName, _ := m["chat_name"].(string)
updateTime, _ := m["update_time"].(string)
feedID, _ := m["feed_id"].(string)
rows = append(rows, map[string]interface{}{
"feed_id": feedID,
"chat_name": chatName,
"update_time": formatFeedGroupUpdateTime(updateTime),
})
}
output.PrintTable(w, rows)
moreHint := ""
if hasMore {
moreHint = " (more available, use --page-token to fetch next page)"
}
fmt.Fprintf(w, "\n%d item(s)%s\n", len(items), moreHint)
if deleted, _ := data["deleted_items"].([]any); len(deleted) > 0 {
fmt.Fprintf(w, "(%d deleted)\n", len(deleted))
}
}
// formatFeedGroupUpdateTime renders a Unix-millisecond timestamp string as a
// human-readable local time for the pretty table. The raw value is returned
// unchanged when it is empty or not a valid millisecond integer, so JSON output
// (which never calls this) keeps the original wire value.
func formatFeedGroupUpdateTime(raw string) string {
if raw == "" {
return raw
}
ms, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return raw
}
return time.UnixMilli(ms).Local().Format(time.RFC3339)
}

View File

@@ -0,0 +1,234 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"io"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const feedGroupListPath = "/open-apis/im/v1/groups"
// ImFeedGroupList provides the +feed-group-list shortcut: it lists the caller's
// feed groups (tags) with auto-pagination that correctly merges BOTH the live
// (groups) and soft-deleted (deleted_groups) lists across pages.
//
// The raw `feed.groups list --page-all` goes through the generic paginator,
// which follows only one array field and silently drops the other list's later
// pages; this shortcut paginates the dual-list response itself.
var ImFeedGroupList = common.Shortcut{
Service: "im",
Command: "+feed-group-list",
Description: "List the caller's feed groups (tags); user-only; supports `--page-all` auto-pagination",
Risk: "read",
UserScopes: []string{feedGroupReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
{Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"},
{Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFeedGroupListPageOptions(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if err := validateFeedGroupListPageOptions(runtime); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
GET(feedGroupListPath).
Params(feedGroupListGroupsDryRunParams(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// When --page-token is explicitly provided, the user wants a specific
// page — no auto-pagination regardless of --page-all.
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
return executeFeedGroupListGroupsAllPages(runtime)
}
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)
if err != nil {
return err
}
hasMore, _ := data["has_more"].(bool)
runtime.OutFormat(data, nil, func(w io.Writer) {
renderFeedGroupsTable(w, data, hasMore)
})
return nil
},
}
func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error {
if n := rt.Int("page-size"); n < 1 || n > 50 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
}
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
if v := rt.Str("start-time"); v != "" {
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-time must be Unix milliseconds (a decimal integer string)").WithParam("--start-time")
}
}
if v := rt.Str("end-time"); v != "" {
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-time must be Unix milliseconds (a decimal integer string)").WithParam("--end-time")
}
}
return nil
}
// feedGroupListGroupsQuery builds the query parameters. page_token is always
// sent (empty string = first page) because the groups endpoint rejects requests
// that omit it (HTTP 400 "Missing required parameter: page_token").
func feedGroupListGroupsQuery(rt *common.RuntimeContext) larkcore.QueryParams {
params := larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{rt.Str("page-token")},
}
if start := rt.Str("start-time"); start != "" {
params["start_time"] = []string{start}
}
if end := rt.Str("end-time"); end != "" {
params["end_time"] = []string{end}
}
return params
}
// feedGroupListGroupsDryRunParams mirrors feedGroupListGroupsQuery for dry-run display.
func feedGroupListGroupsDryRunParams(rt *common.RuntimeContext) map[string]any {
params := map[string]any{
"page_size": strconv.Itoa(rt.Int("page-size")),
"page_token": rt.Str("page-token"),
}
if start := rt.Str("start-time"); start != "" {
params["start_time"] = start
}
if end := rt.Str("end-time"); end != "" {
params["end_time"] = end
}
return params
}
// executeFeedGroupListGroupsAllPages fetches all pages and merges both the live
// (groups) and soft-deleted (deleted_groups) lists into a single response. It
// merges each array independently so neither list loses its later pages.
func executeFeedGroupListGroupsAllPages(rt *common.RuntimeContext) error {
maxPages := rt.Int("page-limit")
if maxPages < 1 {
maxPages = 20
}
if maxPages > 1000 {
maxPages = 1000
}
// Use make([]any, 0) so empty arrays serialize as [] not null.
allGroups := make([]any, 0)
allDeletedGroups := make([]any, 0)
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__"
for page := 0; page < maxPages; page++ {
// page_token is always sent (empty on the first page) — the groups
// endpoint rejects requests that omit it.
params := larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
"page_token": []string{""},
}
if page > 0 {
params["page_token"] = []string{lastPageToken}
}
if start := rt.Str("start-time"); start != "" {
params["start_time"] = []string{start}
}
if end := rt.Str("end-time"); end != "" {
params["end_time"] = []string{end}
}
data, err := rt.DoAPIJSONTyped("GET", feedGroupListPath, params, nil)
if err != nil {
return err
}
if v, ok := data["groups"].([]any); ok {
allGroups = append(allGroups, v...)
}
if v, ok := data["deleted_groups"].([]any); ok {
allDeletedGroups = append(allDeletedGroups, v...)
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d groups, %d deleted\n",
page+1, len(allGroups), len(allDeletedGroups))
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == prevPageToken {
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
break
}
prevPageToken = lastPageToken
}
merged := map[string]any{
"groups": allGroups,
"deleted_groups": allDeletedGroups,
"has_more": lastHasMore,
"page_token": lastPageToken,
}
rt.OutFormat(merged, nil, func(w io.Writer) {
renderFeedGroupsTable(w, merged, lastHasMore)
})
return nil
}
// renderFeedGroupsTable prints the active groups[] as a table (group_id / name /
// type), followed by a summary line. When hasMore is true a pagination hint is
// appended; when there are deleted groups their count is noted.
func renderFeedGroupsTable(w io.Writer, data map[string]any, hasMore bool) {
groups, _ := data["groups"].([]any)
rows := make([]map[string]interface{}, 0, len(groups))
for _, g := range groups {
m, _ := g.(map[string]any)
if m == nil {
continue
}
id, _ := m["group_id"].(string)
name, _ := m["name"].(string)
typ, _ := m["type"].(string)
rows = append(rows, map[string]interface{}{
"group_id": id,
"name": name,
"type": typ,
})
}
output.PrintTable(w, rows)
moreHint := ""
if hasMore {
moreHint = " (more available, use --page-token to fetch next page)"
}
fmt.Fprintf(w, "\n%d group(s)%s\n", len(groups), moreHint)
if deleted, _ := data["deleted_groups"].([]any); len(deleted) > 0 {
fmt.Fprintf(w, "(%d deleted)\n", len(deleted))
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"fmt"
"io"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// ImFeedGroupListItem provides the +feed-group-list-item shortcut: it lists the
// feed cards inside one feed group and enriches each item with chat_name resolved
// from its feed_id.
var ImFeedGroupListItem = common.Shortcut{
Service: "im",
Command: "+feed-group-list-item",
Description: "List feed cards in a feed group (tag); user-only; enriches each item with chat_name resolved from feed_id; supports --page-all auto-pagination",
Risk: "read",
UserScopes: []string{feedGroupReadScope, chatReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
{Name: "page-token", Desc: "pagination token for next page"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
{Name: "start-time", Desc: "update-time window start (Unix milliseconds as a decimal string)"},
{Name: "end-time", Desc: "update-time window end (Unix milliseconds as a decimal string)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFeedGroupListOptions(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
if err := validateFeedGroupListOptions(runtime); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
GET(feedGroupListItemPath(runtime)).
Params(feedGroupListDryRunParams(runtime)).
Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
// When --page-token is explicitly provided, the user wants a specific page —
// no auto-pagination regardless of --page-all.
if runtime.Bool("page-all") && !runtime.Cmd.Flags().Changed("page-token") {
return executeFeedGroupListAllPages(runtime)
}
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
if err != nil {
return err
}
enrichFeedGroupItemsChatName(runtime, data)
hasMore, _ := data["has_more"].(bool)
runtime.OutFormat(data, nil, func(w io.Writer) {
renderFeedGroupItemsTable(w, data, hasMore)
})
return nil
},
}
func validateFeedGroupListOptions(rt *common.RuntimeContext) error {
if rt.Str("feed-group-id") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-group-id is required").WithParam("--feed-group-id")
}
if n := rt.Int("page-size"); n < 1 || n > 50 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and 50").WithParam("--page-size")
}
if n := rt.Int("page-limit"); n < 1 || n > 1000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be an integer between 1 and 1000").WithParam("--page-limit")
}
if v := rt.Str("start-time"); v != "" {
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--start-time must be Unix milliseconds (a decimal integer string)").WithParam("--start-time")
}
}
if v := rt.Str("end-time"); v != "" {
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-time must be Unix milliseconds (a decimal integer string)").WithParam("--end-time")
}
}
return nil
}
// feedGroupListItemPath builds the list_item endpoint path with the feed_group_id
// segment safely encoded.
func feedGroupListItemPath(rt *common.RuntimeContext) string {
return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/list_item"
}
// feedGroupListQuery builds the query parameters, sending only non-empty values.
func feedGroupListQuery(rt *common.RuntimeContext) larkcore.QueryParams {
params := larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
}
if token := rt.Str("page-token"); token != "" {
params["page_token"] = []string{token}
}
if start := rt.Str("start-time"); start != "" {
params["start_time"] = []string{start}
}
if end := rt.Str("end-time"); end != "" {
params["end_time"] = []string{end}
}
return params
}
// feedGroupListDryRunParams mirrors feedGroupListQuery for dry-run display.
func feedGroupListDryRunParams(rt *common.RuntimeContext) map[string]any {
params := map[string]any{
"page_size": strconv.Itoa(rt.Int("page-size")),
}
if token := rt.Str("page-token"); token != "" {
params["page_token"] = token
}
if start := rt.Str("start-time"); start != "" {
params["start_time"] = start
}
if end := rt.Str("end-time"); end != "" {
params["end_time"] = end
}
return params
}
// executeFeedGroupListAllPages fetches all pages and merges items/deleted_items
// into a single response, then enriches the merged result.
func executeFeedGroupListAllPages(rt *common.RuntimeContext) error {
maxPages := rt.Int("page-limit")
if maxPages < 1 {
maxPages = 20
}
if maxPages > 1000 {
maxPages = 1000
}
// Use make([]any, 0) so empty arrays serialize as [] not null.
allItems := make([]any, 0)
allDeletedItems := make([]any, 0)
var lastHasMore bool
var lastPageToken string
prevPageToken := "__START__"
for page := 0; page < maxPages; page++ {
params := larkcore.QueryParams{
"page_size": []string{strconv.Itoa(rt.Int("page-size"))},
}
if page > 0 {
params["page_token"] = []string{lastPageToken}
}
if start := rt.Str("start-time"); start != "" {
params["start_time"] = []string{start}
}
if end := rt.Str("end-time"); end != "" {
params["end_time"] = []string{end}
}
data, err := rt.DoAPIJSONTyped("GET", feedGroupListItemPath(rt), params, nil)
if err != nil {
return err
}
if v, ok := data["items"].([]any); ok {
allItems = append(allItems, v...)
}
if v, ok := data["deleted_items"].([]any); ok {
allDeletedItems = append(allDeletedItems, v...)
}
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
fmt.Fprintf(rt.IO().ErrOut, "page %d: %d items, %d deleted\n",
page+1, len(allItems), len(allDeletedItems))
if !lastHasMore || lastPageToken == "" {
break
}
if lastPageToken == prevPageToken {
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
break
}
prevPageToken = lastPageToken
}
merged := map[string]any{
"items": allItems,
"deleted_items": allDeletedItems,
"has_more": lastHasMore,
"page_token": lastPageToken,
}
enrichFeedGroupItemsChatName(rt, merged)
rt.OutFormat(merged, nil, func(w io.Writer) {
renderFeedGroupItemsTable(w, merged, lastHasMore)
})
return nil
}

View File

@@ -0,0 +1,257 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
)
func fgGroup(id string) map[string]interface{} {
return map[string]interface{}{"group_id": id, "name": id, "type": "normal"}
}
// TestFeedGroupListPageAllMergesBothLists is the core regression for the
// +feed-group-list shortcut: a dual-list response (groups + deleted_groups) must
// have BOTH lists merged across pages — including active groups that appear only
// on a later page. This is what the raw `feed.groups list --page-all` gets wrong.
func TestFeedGroupListPageAllMergesBothLists(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-all": "true", "page-size": "5"}, &reqs,
func(_ string, page int) (int, interface{}) {
if page == 1 {
// page 1 fills up with mostly deleted groups; the active groups
// g1/g2 here plus one more (g3) on page 2.
return 200, wrapData(map[string]interface{}{
"groups": []interface{}{fgGroup("g1"), fgGroup("g2")},
"deleted_groups": []interface{}{fgGroup("d1"), fgGroup("d2"), fgGroup("d3")},
"page_token": "TKN", "has_more": true,
})
}
return 200, wrapData(map[string]interface{}{
"groups": []interface{}{fgGroup("g3")},
"deleted_groups": []interface{}{fgGroup("d4")},
"page_token": "", "has_more": false,
})
})
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute: %v", err)
}
if got := countFGRequests(reqs, "/groups"); got != 2 {
t.Fatalf("expected 2 groups requests, got %d", got)
}
if got := firstQueryValue(reqs[1].query, "page_token"); got != "TKN" {
t.Errorf("second page token = %q, want TKN", got)
}
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if out == nil {
t.Fatal("stdout buffer missing")
}
var parsed map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("output not JSON: %v\n%s", err, out.String())
}
data, _ := parsed["data"].(map[string]interface{})
if got := len(data["groups"].([]interface{})); got != 3 {
t.Errorf("merged groups = %d, want 3 (active list must include later pages)", got)
}
if got := len(data["deleted_groups"].([]interface{})); got != 4 {
t.Errorf("merged deleted_groups = %d, want 4 (secondary list must also merge)", got)
}
}
// TestFeedGroupListAlwaysSendsPageToken locks the fix for the groups endpoint's
// requirement that page_token be present even on the first page (HTTP 400
// "Missing required parameter: page_token" otherwise).
func TestFeedGroupListAlwaysSendsPageToken(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-size": "10"}, &reqs,
func(_ string, _ int) (int, interface{}) {
return 200, wrapData(map[string]interface{}{
"groups": []interface{}{}, "deleted_groups": []interface{}{},
"page_token": "", "has_more": false,
})
})
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute: %v", err)
}
req := findFGRequest(reqs, "/groups")
if req == nil {
t.Fatal("no /groups request recorded")
}
if _, ok := req.query["page_token"]; !ok {
t.Errorf("first request must carry page_token query param (empty = first page); query=%v", req.query)
}
}
// TestFeedGroupListValidation checks flag validation surfaces clear errors.
func TestFeedGroupListValidation(t *testing.T) {
cases := []struct {
name string
flags map[string]string
want string
}{
{"page-size too small", map[string]string{"page-size": "0"}, "--page-size"},
{"page-size too large", map[string]string{"page-size": "51"}, "--page-size"},
{"page-limit too large", map[string]string{"page-limit": "1001"}, "--page-limit"},
{"bad start-time", map[string]string{"start-time": "notnum"}, "--start-time"},
{"bad end-time", map[string]string{"end-time": "notnum"}, "--end-time"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupList, tc.flags, nil, nil)
err := ImFeedGroupList.Validate(context.Background(), runtime)
if err == nil {
t.Fatalf("expected validation error containing %q, got nil", tc.want)
}
if !bytes.Contains([]byte(err.Error()), []byte(tc.want)) {
t.Errorf("error = %q, want substring %q", err.Error(), tc.want)
}
})
}
}
// TestFeedGroupListSinglePageTableOutput covers the non-page-all Execute path:
// time-window params must reach the query, and the pretty table must render
// group_id / name / type with the summary, pagination hint and deleted count.
func TestFeedGroupListSinglePageTableOutput(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{
"start-time": "100", "end-time": "200",
}, &reqs, func(_ string, _ int) (int, interface{}) {
return 200, wrapData(map[string]interface{}{
"groups": []interface{}{fgGroup("g1"), fgGroup("g2")},
"deleted_groups": []interface{}{fgGroup("d1")},
"page_token": "TKN", "has_more": true,
})
})
runtime.Format = "pretty"
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute: %v", err)
}
if got := countFGRequests(reqs, "/groups"); got != 1 {
t.Fatalf("expected 1 groups request, got %d", got)
}
if got := firstQueryValue(reqs[0].query, "start_time"); got != "100" {
t.Errorf("start_time query = %q, want 100", got)
}
if got := firstQueryValue(reqs[0].query, "end_time"); got != "200" {
t.Errorf("end_time query = %q, want 200", got)
}
out, _ := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
if out == nil {
t.Fatal("stdout buffer missing")
}
got := out.String()
for _, want := range []string{"group_id", "g1", "g2", "2 group(s)", "more available", "(1 deleted)"} {
if !strings.Contains(got, want) {
t.Errorf("table output missing %q; got:\n%s", want, got)
}
}
}
// TestFeedGroupListDryRun locks the dry-run shape: GET /groups with page_size,
// page_token (always present) and the optional time-window params.
func TestFeedGroupListDryRun(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{
"page-size": "10", "page-token": "TKN", "start-time": "100", "end-time": "200",
}, nil, nil)
d := ImFeedGroupList.DryRun(context.Background(), runtime)
calls := dryRunCalls(t, d)
if len(calls) != 1 {
t.Fatalf("expected 1 call, got %d", len(calls))
}
if calls[0]["method"] != "GET" {
t.Errorf("method = %v, want GET", calls[0]["method"])
}
if url, _ := calls[0]["url"].(string); !strings.HasSuffix(url, "/open-apis/im/v1/groups") {
t.Errorf("url = %s", url)
}
params, _ := calls[0]["params"].(map[string]interface{})
for key, want := range map[string]string{
"page_size": "10", "page_token": "TKN", "start_time": "100", "end_time": "200",
} {
if params[key] != want {
t.Errorf("params %s = %v, want %s", key, params[key], want)
}
}
}
func TestFeedGroupListDryRunValidationError(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{"page-size": "0"}, nil, nil)
d := ImFeedGroupList.DryRun(context.Background(), runtime)
m := dryRunJSON(t, d)
errMsg, _ := m["error"].(string)
if !strings.Contains(errMsg, "--page-size") {
t.Errorf("dry-run error = %q, want --page-size validation message", errMsg)
}
}
// TestFeedGroupListPageAllStopsOnRepeatedToken locks the infinite-loop guard:
// when the server keeps returning the same page_token with has_more=true,
// pagination must stop after the repeat and warn on stderr. Also exercises the
// defensive page-limit clamping (Execute is called directly, bypassing Validate).
func TestFeedGroupListPageAllStopsOnRepeatedToken(t *testing.T) {
for _, tc := range []struct {
name string
pageLimit string
}{
{"limit clamped up from 0", "0"},
{"limit clamped down from 1001", "1001"},
} {
t.Run(tc.name, func(t *testing.T) {
var reqs []recordedFGRequest
runtime := newFGRuntime(t, ImFeedGroupList, map[string]string{
"page-all": "true", "page-limit": tc.pageLimit, "start-time": "100", "end-time": "200",
}, &reqs, func(_ string, _ int) (int, interface{}) {
return 200, wrapData(map[string]interface{}{
"groups": []interface{}{fgGroup("g1")},
"deleted_groups": []interface{}{},
"page_token": "SAME", "has_more": true,
})
})
runtime.Format = "pretty" // exercise the page-all table-render path too
if err := ImFeedGroupList.Execute(context.Background(), runtime); err != nil {
t.Fatalf("Execute: %v", err)
}
if got := countFGRequests(reqs, "/groups"); got != 2 {
t.Errorf("expected 2 requests (stop on repeated token), got %d", got)
}
errOut, _ := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "page_token did not change") {
t.Errorf("stderr missing loop warning; got:\n%s", errOut.String())
}
})
}
}
// TestFeedGroupListAPIError checks both Execute paths surface API errors.
func TestFeedGroupListAPIError(t *testing.T) {
for _, tc := range []struct {
name string
flags map[string]string
}{
{"single page", map[string]string{}},
{"page-all", map[string]string{"page-all": "true"}},
} {
t.Run(tc.name, func(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupList, tc.flags, nil,
func(_ string, _ int) (int, interface{}) {
return 200, map[string]interface{}{"code": 99999, "msg": "boom"}
})
if err := ImFeedGroupList.Execute(context.Background(), runtime); err == nil {
t.Fatal("expected API error, got nil")
}
})
}
}

View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// ImFeedGroupQueryItem provides the +feed-group-query-item shortcut: it looks up
// specific feed cards in a feed group by ID and enriches each item with chat_name
// resolved from its feed_id.
var ImFeedGroupQueryItem = common.Shortcut{
Service: "im",
Command: "+feed-group-query-item",
Description: "Look up specific feed cards in a feed group (tag) by ID; user-only; enriches each item with chat_name resolved from feed_id",
Risk: "read",
UserScopes: []string{feedGroupReadScope, chatReadScope},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "feed-group-id", Desc: "feed group ID (ofg_xxx); path parameter (required)"},
{Name: "feed-id", Desc: "comma-separated chat IDs (oc_xxx); feed_type is fixed to chat (required)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildFeedGroupQueryItemBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildFeedGroupQueryItemBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST(feedGroupQueryItemPath(runtime)).
Body(body).
Desc("will also POST /open-apis/im/v1/chats/batch_query to resolve chat_name from feed_id; requires im:chat:read")
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildFeedGroupQueryItemBody(runtime)
if err != nil {
return err
}
data, err := runtime.DoAPIJSONTyped("POST", feedGroupQueryItemPath(runtime), nil, body)
if err != nil {
return err
}
enrichFeedGroupItemsChatName(runtime, data)
runtime.OutFormat(data, nil, func(w io.Writer) {
renderFeedGroupItemsTable(w, data, false)
})
return nil
},
}
// feedGroupQueryItemPath builds the batch_query_item endpoint path with the
// feed_group_id segment safely encoded.
func feedGroupQueryItemPath(rt *common.RuntimeContext) string {
return "/open-apis/im/v1/groups/" + validate.EncodePathSegment(rt.Str("feed-group-id")) + "/batch_query_item"
}
// buildFeedGroupQueryItemBody validates the flags and constructs the request body
// {"items":[{"feed_id":"<tok>","feed_type":"chat"}, ...]}.
func buildFeedGroupQueryItemBody(rt *common.RuntimeContext) (map[string]any, error) {
if rt.Str("feed-group-id") == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-group-id is required").WithParam("--feed-group-id")
}
tokens := common.SplitCSV(rt.Str("feed-id"))
items := make([]any, 0, len(tokens))
for _, tok := range tokens {
if tok == "" {
continue
}
items = append(items, map[string]any{
"feed_id": tok,
"feed_type": "chat",
})
}
if len(items) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--feed-id is required (comma-separated chat IDs)").WithParam("--feed-id")
}
return map[string]any{"items": items}, nil
}

View File

@@ -15,14 +15,13 @@ import (
// ImFlagCancel provides the +flag-cancel shortcut for removing a bookmark.
// When no --flag-type is given, it performs double-cancel: removes both message and feed layers.
var ImFlagCancel = common.Shortcut{
Service: "im",
Command: "+flag-cancel",
Description: "Cancel (remove) a bookmark. When no --flag-type is given, " +
"performs double-cancel: removes both message and feed layers",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Service: "im",
Command: "+flag-cancel",
Description: "Cancel (remove) a bookmark. When no --flag-type is given, best-effort double-cancel: removes message layer and (when chat_type is determinable) feed layer",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "message-id", Desc: "message ID (om_xxx)"},
{Name: "item-type", Desc: "item type override: default|thread|msg_thread"},

View File

@@ -16,7 +16,7 @@ import (
var ImFlagCreate = common.Shortcut{
Service: "im",
Command: "+flag-create",
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed to create feed-layer flag (auto-detects chat type)",
Description: "Create a bookmark on a message; user-only; defaults to message-layer flag; use --flag-type feed for feed-layer flag (item_type auto-detected from chat mode)",
Risk: "write",
UserScopes: flagWriteLookupScopes,
AuthTypes: []string{"user"},

View File

@@ -25,5 +25,8 @@ func Shortcuts() []common.Shortcut {
ImFeedShortcutCreate,
ImFeedShortcutRemove,
ImFeedShortcutList,
ImFeedGroupList,
ImFeedGroupListItem,
ImFeedGroupQueryItem,
}
}

View File

@@ -13,10 +13,12 @@ import (
"path"
"path/filepath"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
@@ -28,8 +30,17 @@ const markdownEmptyContentError = "empty markdown content is not supported; cann
const (
markdownUploadParentTypeExplorer = "explorer"
markdownUploadParentTypeWiki = "wiki"
markdownUploadAllAction = "upload markdown file failed"
markdownUploadPrepareAction = "initialize markdown multipart upload failed"
markdownUploadFinishAction = "finalize markdown multipart upload failed"
markdownFetchNameAction = "fetch existing markdown file name failed"
)
var markdownUploadRetryBackoffs = []time.Duration{
200 * time.Millisecond,
500 * time.Millisecond,
}
type markdownUploadSpec struct {
FileToken string
FileName string
@@ -387,58 +398,68 @@ func uploadMarkdownContent(runtime *common.RuntimeContext, spec markdownUploadSp
fileName := finalMarkdownFileName(spec)
fileSize := int64(len(payload))
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(payload)), nil
})
}
return uploadMarkdownFileAll(runtime, spec, bytes.NewReader(payload), fileName, fileSize)
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(payload)), nil
})
}
func uploadMarkdownLocalFile(runtime *common.RuntimeContext, spec markdownUploadSpec, fileSize int64) (markdownUploadResult, error) {
fileName := finalMarkdownFileName(spec)
f, err := runtime.FileIO().Open(spec.FilePath)
if err != nil {
return markdownUploadResult{}, common.WrapInputStatError(err)
}
defer f.Close()
if fileSize > markdownSinglePartSizeLimit {
return uploadMarkdownFileMultipart(runtime, spec, f, fileName, fileSize)
return uploadMarkdownFileMultipart(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return runtime.FileIO().Open(spec.FilePath)
})
}
return uploadMarkdownFileAll(runtime, spec, f, fileName, fileSize)
return uploadMarkdownFileAll(runtime, spec, fileName, fileSize, func() (io.ReadCloser, error) {
return runtime.FileIO().Open(spec.FilePath)
})
}
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
target := spec.Target()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return markdownUploadResult{}, err
return withMarkdownUploadRetryResult(runtime, markdownUploadAllAction, func() (markdownUploadResult, error) {
fileReader, err := openReader()
if err != nil {
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
}
return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err)
}
defer fileReader.Close()
data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed")
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(data, spec.FileToken != "")
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", target.ParentType)
fd.AddField("parent_node", target.ParentNode)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
if spec.FileToken != "" {
fd.AddField("file_token", spec.FileToken)
}
fd.AddFile("file", fileReader)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(client.WrapDoAPIError(err), markdownUploadAllAction)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
}
result, err := parseMarkdownUploadResult(data, spec.FileToken != "")
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadAllAction)
}
return result, nil
})
}
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileReader io.Reader, fileName string, fileSize int64) (markdownUploadResult, error) {
func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, fileName string, fileSize int64, openReader func() (io.ReadCloser, error)) (markdownUploadResult, error) {
target := spec.Target()
prepareBody := map[string]interface{}{
"file_name": fileName,
@@ -450,31 +471,53 @@ func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUp
prepareBody["file_token"] = spec.FileToken
}
prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
prepareResult, err := withMarkdownUploadRetryData(runtime, markdownUploadPrepareAction, func() (map[string]interface{}, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
if err != nil {
return nil, markdownUploadProblem(err, markdownUploadPrepareAction)
}
return data, nil
})
if err != nil {
return markdownUploadResult{}, err
}
session, err := parseMarkdownMultipartSession(prepareResult)
if err != nil {
return markdownUploadResult{}, err
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadPrepareAction)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize))
fileReader, err := openReader()
if err != nil {
return markdownUploadResult{}, common.WrapInputStatErrorTyped(err)
}
defer fileReader.Close()
if err := uploadMarkdownMultipartParts(runtime, fileReader, fileSize, session); err != nil {
return markdownUploadResult{}, err
}
finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": session.UploadID,
"block_num": session.BlockNum,
finishResult, err := withMarkdownUploadRetryData(runtime, markdownUploadFinishAction, func() (map[string]interface{}, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{
"upload_id": session.UploadID,
"block_num": session.BlockNum,
})
if err != nil {
return nil, markdownUploadProblem(err, markdownUploadFinishAction)
}
return data, nil
})
if err != nil {
return markdownUploadResult{}, err
}
return parseMarkdownUploadResult(finishResult, spec.FileToken != "")
result, err := parseMarkdownUploadResult(finishResult, spec.FileToken != "")
if err != nil {
return markdownUploadResult{}, markdownUploadProblem(err, markdownUploadFinishAction)
}
return result, nil
}
func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) {
@@ -484,7 +527,7 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
BlockNum: int(common.GetFloat(data, "block_num")),
}
if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 {
return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error",
return markdownMultipartSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
session.UploadID, session.BlockSize, session.BlockNum)
}
@@ -494,9 +537,8 @@ func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipa
func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.Reader, payloadSize int64, session markdownMultipartSession) error {
expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize)
if session.BlockNum != expectedBlocks {
return output.Errorf(
output.ExitAPI,
"api_error",
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d",
session.BlockSize,
session.BlockNum,
@@ -507,7 +549,7 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
maxInt := int64(^uint(0) >> 1)
if session.BlockSize > maxInt {
return output.Errorf(output.ExitAPI, "api_error", "upload prepare failed: invalid block_size returned")
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
}
buffer := make([]byte, int(session.BlockSize))
@@ -528,22 +570,27 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
fd.AddField("upload_id", session.UploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", n))
fd.AddFile("file", bytes.NewReader(buffer[:n]))
action := fmt.Sprintf("upload markdown file part %d/%d failed", seq+1, session.BlockNum)
if err := withMarkdownUploadRetryVoid(runtime, action, func() error {
fd := larkcore.NewFormdata()
fd.AddField("upload_id", session.UploadID)
fd.AddField("seq", fmt.Sprintf("%d", seq))
fd.AddField("size", fmt.Sprintf("%d", n))
fd.AddFile("file", bytes.NewReader(buffer[:n]))
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return markdownUploadProblem(client.WrapDoAPIError(err), action)
}
return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err)
}
if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil {
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return markdownUploadProblem(err, action)
}
return nil
}); err != nil {
return err
}
@@ -551,9 +598,8 @@ func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, fileReader io.
remaining -= int64(n)
}
if remaining != 0 {
return output.Errorf(
output.ExitAPI,
"api_error",
return errs.NewInternalError(
errs.SubtypeInvalidResponse,
"upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks",
remaining,
session.BlockNum,
@@ -572,28 +618,34 @@ func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool)
result.Version = common.GetString(data, "data_version")
}
if result.FileToken == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
}
if requireVersion && result.Version == "" {
return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned")
return markdownUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "overwrite failed: no version returned")
}
return result, nil
}
func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
data, err := withMarkdownUploadRetryData(runtime, markdownFetchNameAction, func() (map[string]interface{}, error) {
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": fileToken,
"doc_type": "file",
},
},
},
},
)
)
if err != nil {
return nil, markdownUploadProblem(err, markdownFetchNameAction)
}
return data, nil
})
if err != nil {
return "", err
}
@@ -606,6 +658,97 @@ func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (st
return common.GetString(meta, "title"), nil
}
func withMarkdownUploadRetryResult(runtime *common.RuntimeContext, action string, fn func() (markdownUploadResult, error)) (markdownUploadResult, error) {
var zero markdownUploadResult
for attempt := 0; ; attempt++ {
result, err := fn()
if err == nil {
return result, nil
}
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
return zero, markdownUploadRetryExhausted(err, action, attempt)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
time.Sleep(markdownUploadRetryBackoffs[attempt])
}
}
func withMarkdownUploadRetryData(runtime *common.RuntimeContext, action string, fn func() (map[string]interface{}, error)) (map[string]interface{}, error) {
for attempt := 0; ; attempt++ {
result, err := fn()
if err == nil {
return result, nil
}
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
return nil, markdownUploadRetryExhausted(err, action, attempt)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
time.Sleep(markdownUploadRetryBackoffs[attempt])
}
}
func withMarkdownUploadRetryVoid(runtime *common.RuntimeContext, action string, fn func() error) error {
for attempt := 0; ; attempt++ {
err := fn()
if err == nil {
return nil
}
if !markdownUploadShouldRetry(err) || attempt >= len(markdownUploadRetryBackoffs) {
return markdownUploadRetryExhausted(err, action, attempt)
}
fmt.Fprintf(runtime.IO().ErrOut, "%s; retrying (attempt %d/%d)\n", err.Error(), attempt+1, len(markdownUploadRetryBackoffs))
time.Sleep(markdownUploadRetryBackoffs[attempt])
}
}
func markdownUploadShouldRetry(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok || p == nil {
return false
}
return p.Retryable || p.Category == errs.CategoryNetwork
}
func markdownUploadRetryExhausted(err error, action string, retries int) error {
if retries <= 0 {
return err
}
return appendMarkdownProblemHint(err, fmt.Sprintf("%s remained retryable after %d attempts; retry later if the upstream service is throttling or temporarily unavailable", action, retries+1))
}
func markdownUploadProblem(err error, action string) error {
if p, ok := errs.ProblemOf(err); ok {
p.Message = action + ": " + p.Message
switch p.Code {
case 99991672, 99991679:
appendMarkdownProblemHint(err, "The current token or identity lacks the required document upload scope/capability. Grant the document upload scope or use a token with the appropriate permissions, then retry.")
case 10071:
appendMarkdownProblemHint(err, "The target document has reached its version limit. Clean up old versions or create a new file before retrying.")
case 90003087:
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
case 1061003, 1061044:
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
case 1061004, 1062501:
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
}
}
return err
}
func appendMarkdownProblemHint(err error, hint string) error {
if strings.TrimSpace(hint) == "" {
return err
}
if p, ok := errs.ProblemOf(err); ok {
if strings.TrimSpace(p.Hint) != "" {
p.Hint = p.Hint + "\n" + hint
} else {
p.Hint = hint
}
}
return err
}
func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) {
fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token"))
fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name"))

View File

@@ -17,9 +17,11 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -603,6 +605,100 @@ func TestMarkdownCreateSuccessUploadAllToWikiReturnsMetaURL(t *testing.T) {
}
}
func TestMarkdownCreateUploadAllReturnsTypedScopeError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 99991672,
"msg": "Access denied. One of the following scopes is required: [drive:file:upload]",
"error": map[string]interface{}{
"log_id": "log-md-upload-scope",
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
}, f, stdout)
if err == nil {
t.Fatal("expected scope error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 99991672 {
t.Fatalf("code = %d, want 99991672", p.Code)
}
if p.Subtype != errs.SubtypeAppScopeNotApplied {
t.Fatalf("subtype = %s, want %s", p.Subtype, errs.SubtypeAppScopeNotApplied)
}
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
}
if !strings.Contains(p.Hint, "lacks the required document upload scope") {
t.Fatalf("hint = %q, want upload scope guidance", p.Hint)
}
}
func TestMarkdownCreateUploadAllRetriesRateLimit(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 99991400,
"msg": "request frequency limit exceeded",
"error": map[string]interface{}{
"log_id": "log-md-upload-ratelimit-1",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "box_md_retry_success",
"version": "1003",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": "box_md_retry_success", "doc_type": "file", "url": "https://tenant.example.com/file/box_md_retry_success"},
},
},
},
})
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello\n",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "retrying (attempt 1/2)") {
t.Fatalf("stderr = %q, want retry log", stderr.String())
}
if !strings.Contains(stdout.String(), `"file_token": "box_md_retry_success"`) {
t.Fatalf("stdout missing retried upload token: %s", stdout.String())
}
}
func TestMarkdownCreatePrettyOutputIncludesPermissionGrant(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -1033,6 +1129,270 @@ func TestUploadMarkdownMultipartPartsRejectsOversizedBlockSize(t *testing.T) {
}
}
func TestWithMarkdownUploadRetryDataDoesNotRetryNonRetryable(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
attempts := 0
expected := errs.NewAPIError(errs.SubtypePermissionDenied, "permission denied").WithCode(1061004)
_, err := withMarkdownUploadRetryData(rt, markdownUploadAllAction, func() (map[string]interface{}, error) {
attempts++
return nil, expected
})
if err != expected {
t.Fatalf("err = %v, want original error", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
if stderr.String() != "" {
t.Fatalf("stderr = %q, want no retry log", stderr.String())
}
}
func TestWithMarkdownUploadRetryVoidExhaustedAppendsHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, markdownTestConfig())
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser)
orig := markdownUploadRetryBackoffs
markdownUploadRetryBackoffs = []time.Duration{0, 0}
t.Cleanup(func() { markdownUploadRetryBackoffs = orig })
attempts := 0
err := withMarkdownUploadRetryVoid(rt, markdownUploadFinishAction, func() error {
attempts++
return errs.NewAPIError(errs.SubtypeRateLimit, "too many requests").WithCode(99991400).WithRetryable()
})
if err == nil {
t.Fatal("expected retryable error")
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.Contains(p.Hint, "remained retryable after 3 attempts") {
t.Fatalf("hint = %q, want retry exhaustion guidance", p.Hint)
}
if strings.Count(stderr.String(), "retrying (attempt") != 2 {
t.Fatalf("stderr = %q, want 2 retry logs", stderr.String())
}
}
func TestMarkdownUploadShouldRetryBranches(t *testing.T) {
if markdownUploadShouldRetry(errors.New("plain")) {
t.Fatal("plain error should not be retryable")
}
if !markdownUploadShouldRetry(errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()) {
t.Fatal("retryable API error should be retryable")
}
if !markdownUploadShouldRetry(errs.NewNetworkError(errs.SubtypeNetworkServer, "gateway").WithCode(502)) {
t.Fatal("network error should be retryable by category")
}
}
func TestMarkdownUploadRetryExhaustedZeroRetriesKeepsOriginal(t *testing.T) {
original := errs.NewAPIError(errs.SubtypeRateLimit, "slow down").WithRetryable()
got := markdownUploadRetryExhausted(original, markdownUploadAllAction, 0)
if got != original {
t.Fatalf("got = %v, want original error", got)
}
}
func TestMarkdownUploadProblemAppendsCodeSpecificHints(t *testing.T) {
tests := []struct {
name string
code int
want string
}{
{
name: "missing scope",
code: 99991672,
want: "lacks the required document upload scope",
},
{
name: "version limit",
code: 10071,
want: "reached its version limit",
},
{
name: "document capability",
code: 90003087,
want: "document capabilities enabled",
},
{
name: "target not found",
code: 1061044,
want: "target folder or wiki node still exists",
},
{
name: "no write access",
code: 1062501,
want: "has write access",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(tt.code)
got := markdownUploadProblem(err, markdownUploadAllAction)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", got, got)
}
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
t.Fatalf("message = %q, want action prefix", p.Message)
}
if !strings.Contains(p.Hint, tt.want) {
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.want)
}
})
}
}
func TestUploadMarkdownFileAllMissingFileTokenGetsActionPrefix(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": "1001",
},
},
})
_, err := uploadMarkdownFileAll(
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
markdownUploadSpec{ContentSet: true},
"README.md",
int64(len("# hello\n")),
func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("# hello\n")), nil
},
)
if err == nil {
t.Fatal("expected parse error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.HasPrefix(p.Message, markdownUploadAllAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadAllAction+": ")
}
}
func TestUploadMarkdownFileMultipartPrepareAndFinishParseErrorsGetActionPrefix(t *testing.T) {
t.Run("prepare", func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_num": 1,
},
},
})
_, err := uploadMarkdownFileMultipart(
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
markdownUploadSpec{ContentSet: true},
"README.md",
int64(len("# hello\n")),
func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("# hello\n")), nil
},
)
if err == nil {
t.Fatal("expected prepare parse error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.HasPrefix(p.Message, markdownUploadPrepareAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadPrepareAction+": ")
}
})
t.Run("finish", func(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_123",
"block_size": float64(8),
"block_num": float64(1),
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"version": "1001",
},
},
})
_, err := uploadMarkdownFileMultipart(
common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create"}, markdownTestConfig(), f, core.AsUser),
markdownUploadSpec{ContentSet: true},
"README.md",
int64(len("# hello\n")),
func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader("# hello\n")), nil
},
)
if err == nil {
t.Fatal("expected finish parse error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if !strings.HasPrefix(p.Message, markdownUploadFinishAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownUploadFinishAction+": ")
}
})
}
func TestAppendMarkdownProblemHintAppendsAndIgnoresBlank(t *testing.T) {
err := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("first")
appendMarkdownProblemHint(err, "second")
appendMarkdownProblemHint(err, " ")
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Hint != "first\nsecond" {
t.Fatalf("hint = %q, want newline-joined hints", p.Hint)
}
plain := errors.New("plain")
if got := appendMarkdownProblemHint(plain, "ignored"); got != plain {
t.Fatalf("plain error should pass through unchanged")
}
}
func TestMarkdownOverwriteUploadAllIncludesFileTokenAndVersion(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
@@ -1303,7 +1663,18 @@ func TestMarkdownOverwriteRejectsEmptyLocalFile(t *testing.T) {
}
func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 1061044,
"msg": "parent node not exist",
"error": map[string]interface{}{
"log_id": "log-md-meta-notfound",
},
},
})
err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{
"+overwrite",
@@ -1313,6 +1684,19 @@ func TestMarkdownOverwriteMetadataLookupFailure(t *testing.T) {
if err == nil {
t.Fatal("expected metadata lookup failure")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1061044 {
t.Fatalf("code = %d, want 1061044", p.Code)
}
if !strings.HasPrefix(p.Message, markdownFetchNameAction+": ") {
t.Fatalf("message = %q, want %q prefix", p.Message, markdownFetchNameAction+": ")
}
if !strings.Contains(p.Hint, "target folder or wiki node still exists") {
t.Fatalf("hint = %q, want target guidance", p.Hint)
}
}
func TestMarkdownOverwriteMissingFileReturnsReadError(t *testing.T) {

View File

@@ -17,6 +17,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -54,29 +55,29 @@ var MinutesDownload = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens"))
if len(tokens) == 0 {
return output.ErrValidation("--minute-tokens is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens")
}
if len(tokens) > maxBatchSize {
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
}
for _, token := range tokens {
if !validMinuteToken.MatchString(token) {
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens")
}
}
// Cheap checks first, then path-safety resolution.
out := runtime.Str("output")
outDir := runtime.Str("output-dir")
if out != "" && outDir != "" {
return output.ErrValidation("--output and --output-dir cannot both be set")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output")
}
if out != "" {
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
return err
}
}
if outDir != "" {
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
return err
}
}
@@ -112,7 +113,7 @@ var MinutesDownload = common.Shortcut{
explicitOutputPath = ""
case statErr == nil && !fi.IsDir():
if !single {
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output")
}
case errors.Is(statErr, fs.ErrNotExist):
if !single {
@@ -120,7 +121,7 @@ var MinutesDownload = common.Shortcut{
explicitOutputPath = ""
}
default:
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr)
}
}
@@ -137,6 +138,7 @@ var MinutesDownload = common.Shortcut{
SizeBytes int64 `json:"size_bytes,omitempty"`
DownloadURL string `json:"download_url,omitempty"`
Error string `json:"error,omitempty"`
err error // raw typed error for single-mode passthrough
}
results := make([]result, len(tokens))
@@ -151,18 +153,18 @@ var MinutesDownload = common.Shortcut{
// download URLs originate from the trusted Lark API, not user input.
baseClient, err := runtime.Factory.HttpClient()
if err != nil {
return output.ErrNetwork("failed to get HTTP client: %s", err)
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
}
clonedClient := *baseClient
clonedClient.Timeout = disableClientTimeout
clonedClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= maxDownloadRedirects {
return fmt.Errorf("too many redirects")
return fmt.Errorf("too many redirects") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
}
if len(via) > 0 {
prev := via[len(via)-1]
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
return fmt.Errorf("redirect from https to http is not allowed")
return fmt.Errorf("redirect from https to http is not allowed") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
}
}
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
@@ -193,7 +195,7 @@ var MinutesDownload = common.Shortcut{
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
if err != nil {
results[i] = result{MinuteToken: token, Error: err.Error()}
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
continue
}
@@ -220,7 +222,7 @@ var MinutesDownload = common.Shortcut{
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
if err != nil {
results[i] = result{MinuteToken: token, Error: err.Error()}
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
continue
}
results[i] = result{
@@ -235,7 +237,10 @@ var MinutesDownload = common.Shortcut{
if single {
r := results[0]
if r.Error != "" {
return output.ErrAPI(0, r.Error, nil)
if r.err != nil {
return r.err // typed error from fetchDownloadURL/downloadMediaFile, exit code preserved
}
return runtime.OutPartialFailure(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)})
}
if urlOnly {
runtime.Out(map[string]interface{}{
@@ -262,17 +267,19 @@ var MinutesDownload = common.Shortcut{
}
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
outData := map[string]interface{}{"downloads": results}
meta := &output.Meta{Count: len(results)}
if successCount == 0 && len(results) > 0 {
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
return runtime.OutPartialFailure(outData, meta)
}
runtime.OutFormat(outData, meta, nil)
return nil
},
}
// fetchDownloadURL retrieves the pre-signed download URL for a minute token.
func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) (string, error) {
data, err := runtime.DoAPIJSON(http.MethodGet,
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
nil, nil)
if err != nil {
@@ -280,7 +287,7 @@ func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minut
}
downloadURL := common.GetString(data, "download_url")
if downloadURL == "" {
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned empty download_url for %s", minuteToken)
}
return downloadURL, nil
}
@@ -302,26 +309,26 @@ type downloadOpts struct {
// Filename resolution: opts.outputPath > Content-Disposition filename > Content-Type ext > <token>.media.
func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, minuteToken string, opts downloadOpts) (*downloadResult, error) {
if err := validate.ValidateDownloadSourceURL(ctx, downloadURL); err != nil {
return nil, output.ErrValidation("blocked download URL: %s", err)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked download URL: %s", err).WithCause(err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, output.ErrNetwork("invalid download URL: %s", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err)
}
resp, err := client.Do(req)
if err != nil {
return nil, output.ErrNetwork("download failed: %s", err)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if len(body) > 0 {
return nil, output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
}
// resolve output path
@@ -340,7 +347,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
if !opts.overwrite {
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", outputPath)
}
}
@@ -349,7 +356,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, common.WrapSaveErrorByCategory(err, "io")
return nil, common.WrapSaveErrorTyped(err)
}
resolvedPath, err := opts.fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
@@ -15,9 +16,11 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -694,3 +697,284 @@ func TestDownload_Batch_DryRun(t *testing.T) {
t.Errorf("dry-run should show tokens, got: %s", out)
}
}
// ---------------------------------------------------------------------------
// Typed-error lock tests
// ---------------------------------------------------------------------------
// TestDownload_TypedErr_ValidationInvalidArgument verifies that an invalid
// minute token format (passing cobra's required check but failing our regex)
// returns a *errs.ValidationError with SubtypeInvalidArgument and the expected
// Param. This locks site :64 (invalid minute token %q).
func TestDownload_TypedErr_ValidationInvalidArgument(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, MinutesDownload, []string{
"+download", "--minute-tokens", "INVALID***TOKEN", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--minute-tokens" {
t.Errorf("Param = %q, want %q", ve.Param, "--minute-tokens")
}
}
// TestDownload_TypedErr_NetworkTransport_HttpError verifies that a non-2xx
// download response from downloadMediaFile returns a *errs.NetworkError with
// SubtypeNetworkTransport.
//
// In the end-to-end single-token Execute path the typed error is now passed
// through directly via r.err (single-mode passthrough). We call downloadMediaFile
// directly via a probe shortcut to assert the typed shape at the source.
func TestDownload_TypedErr_NetworkTransport_HttpError(t *testing.T) {
chdir(t, t.TempDir())
var capturedErr error
probe := common.Shortcut{
Service: "minutes",
Command: "+probe-dl",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
client, err := rctx.Factory.HttpClient()
if err != nil {
return err
}
_, capturedErr = downloadMediaFile(ctx, client,
"https://example.com/presigned/download", "tok001",
downloadOpts{fio: rctx.FileIO(), outputPath: "out.mp4"})
return nil
},
}
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
URL: "example.com/presigned/download",
Status: 503,
RawBody: []byte("Service Unavailable"),
})
if err := mountAndRun(t, probe, []string{"+probe-dl", "--as", "bot"}, f, nil); err != nil {
t.Fatalf("probe shortcut should not error: %v", err)
}
if capturedErr == nil {
t.Fatal("expected downloadMediaFile to return an error for HTTP 503, got nil")
}
var ne *errs.NetworkError
if !errors.As(capturedErr, &ne) {
t.Fatalf("expected *errs.NetworkError, got %T: %v", capturedErr, capturedErr)
}
if ne.Subtype != errs.SubtypeNetworkTransport {
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
}
if !strings.Contains(ne.Error(), "503") {
t.Errorf("error message should contain status code 503, got: %v", ne)
}
}
// TestDownload_TypedErr_InternalInvalidResponse verifies that fetchDownloadURL
// returns *errs.InternalError with SubtypeInvalidResponse when the API
// response contains an empty download_url field.
//
// In the end-to-end single-token Execute path the typed error is now passed
// through directly via r.err (single-mode passthrough). The typed assertion
// is also made at the fetchDownloadURL call site directly via a probe shortcut.
func TestDownload_TypedErr_InternalInvalidResponse(t *testing.T) {
var capturedErr error
probe := common.Shortcut{
Service: "minutes",
Command: "+probe-download-url",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, capturedErr = fetchDownloadURL(ctx, rctx, "tok001")
// Always return nil so mountAndRun doesn't swallow the error type.
return nil
},
}
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tok001/media",
Status: 200,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"download_url": ""},
},
})
if err := mountAndRun(t, probe, []string{"+probe-download-url", "--as", "bot"}, f, nil); err != nil {
t.Fatalf("probe shortcut should not error: %v", err)
}
if capturedErr == nil {
t.Fatal("expected fetchDownloadURL to return an error for empty download_url, got nil")
}
var ie *errs.InternalError
if !errors.As(capturedErr, &ie) {
t.Fatalf("expected *errs.InternalError, got %T: %v", capturedErr, capturedErr)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
}
if !strings.Contains(ie.Error(), "download_url") {
t.Errorf("error message should mention download_url, got: %v", ie)
}
}
// TestDownload_TypedErr_OverwriteProtection verifies that the overwrite guard
// in downloadMediaFile returns *errs.ValidationError with SubtypeFailedPrecondition.
//
// In the end-to-end single-token Execute path this typed error is now passed
// through directly via r.err (single-mode passthrough), so the typed shape is
// also asserted end-to-end via the probe shortcut.
func TestDownload_TypedErr_OverwriteProtection(t *testing.T) {
chdir(t, t.TempDir())
if err := os.WriteFile("existing.mp4", []byte("old"), 0644); err != nil {
t.Fatalf("setup failed: %v", err)
}
var capturedErr error
probe := common.Shortcut{
Service: "minutes",
Command: "+probe-overwrite",
AuthTypes: []string{"bot"},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
client, err := rctx.Factory.HttpClient()
if err != nil {
return err
}
_, capturedErr = downloadMediaFile(ctx, client,
"https://example.com/presigned/download", "tok001",
downloadOpts{fio: rctx.FileIO(), outputPath: "existing.mp4", overwrite: false})
return nil
},
}
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(downloadStub("example.com/presigned/download", []byte("new-content"), "video/mp4"))
if err := mountAndRun(t, probe, []string{"+probe-overwrite", "--as", "bot"}, f, nil); err != nil {
t.Fatalf("probe shortcut should not error: %v", err)
}
if capturedErr == nil {
t.Fatal("expected downloadMediaFile to return an error for existing file without overwrite, got nil")
}
var ve *errs.ValidationError
if !errors.As(capturedErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", capturedErr, capturedErr)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if !strings.Contains(ve.Error(), "exists") {
t.Errorf("error message should mention exists, got: %v", ve)
}
}
// TestDownload_TypedErr_SingleMode_PassthroughTyped verifies that in single-token
// mode a typed error from fetchDownloadURL or downloadMediaFile is returned
// directly to the caller with its Problem shape intact (exit code preserved).
func TestDownload_TypedErr_SingleMode_PassthroughTyped(t *testing.T) {
chdir(t, t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// API returns non-zero code → CallAPITyped yields a typed APIError.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tok001/media",
Status: 200,
Body: map[string]interface{}{
"code": 99991, "msg": "permission denied",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, MinutesDownload, []string{
"+download", "--minute-tokens", "tok001", "--output", "out.mp4", "--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected error for API failure, got nil")
}
// The error must carry a Problem (typed envelope).
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error (ProblemOf ok), got %T: %v", err, err)
}
if p == nil {
t.Fatal("ProblemOf returned nil Problem")
}
// Exit code must be non-zero and come from the typed error, not a generic 1.
code := output.ExitCodeOf(err)
if code == 0 {
t.Errorf("ExitCodeOf typed error = 0, want non-zero")
}
}
// TestDownload_TypedErr_Batch_AllFail_OutPartialFailure verifies that when every
// token in a batch fails, Execute emits an ok:false stdout envelope (carrying the
// full downloads array) and returns *output.PartialFailureError with Code==ExitAPI.
// This locks the double-emit fix: the old code called OutFormat then returned ErrAPI;
// the new code calls OutPartialFailure once.
func TestDownload_TypedErr_Batch_AllFail_OutPartialFailure(t *testing.T) {
chdir(t, t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Both tokens fail at the API level.
for _, tok := range []string{"tok001", "tok002"} {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + tok + "/media",
Status: 200,
Body: map[string]interface{}{
"code": 99991, "msg": "permission denied",
"data": map[string]interface{}{},
},
})
}
err := mountAndRun(t, MinutesDownload, []string{
"+download", "--minute-tokens", "tok001,tok002", "--as", "bot",
}, f, stdout)
// Must return *output.PartialFailureError with ExitAPI.
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
if pfErr.Code != output.ExitAPI {
t.Errorf("PartialFailureError.Code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
// stdout must carry ok:false with the downloads array (both failed entries).
var env struct {
OK bool `json:"ok"`
Data struct {
Downloads []struct {
MinuteToken string `json:"minute_token"`
Error string `json:"error"`
} `json:"downloads"`
} `json:"data"`
}
if jsonErr := json.Unmarshal(stdout.Bytes(), &env); jsonErr != nil {
t.Fatalf("failed to parse stdout: %v\nraw: %s", jsonErr, stdout.String())
}
if env.OK {
t.Errorf("ok must be false on all-fail batch, got ok:true\nstdout: %s", stdout.String())
}
if len(env.Data.Downloads) != 2 {
t.Fatalf("expected 2 download entries, got %d\nstdout: %s", len(env.Data.Downloads), stdout.String())
}
for _, d := range env.Data.Downloads {
if d.Error == "" {
t.Errorf("token %s: expected non-empty error field in all-fail batch", d.MinuteToken)
}
}
}

View File

@@ -13,6 +13,7 @@ import (
"time"
"unicode/utf8"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -35,28 +36,28 @@ func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
if start != "" {
parsed, err := toRFC3339(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startTime = parsed
}
if end != "" {
parsed, err := toRFC3339(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endTime = parsed
}
if startTime != "" && endTime != "" {
st, err := time.Parse(time.RFC3339, startTime)
if err != nil {
return "", "", fmt.Errorf("parse normalized --start: %w", err)
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "parse normalized --start: %v", err).WithCause(err)
}
et, err := time.Parse(time.RFC3339, endTime)
if err != nil {
return "", "", fmt.Errorf("parse normalized --end: %w", err)
return "", "", errs.NewInternalError(errs.SubtypeUnknown, "parse normalized --end: %v", err).WithCause(err)
}
if st.After(et) {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start (%s) is after --end (%s)", start, end).WithParam("--start")
}
}
return startTime, endTime, nil
@@ -70,7 +71,7 @@ func toRFC3339(input string, hint ...string) (string, error) {
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err) //nolint:forbidigo // intermediate parse error; callers wrap it into a typed ValidationError
}
return time.Unix(sec, 0).Format(time.RFC3339), nil
}
@@ -94,7 +95,7 @@ func buildTimeFilter(startTime, endTime string) map[string]interface{} {
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
filter := map[string]interface{}{}
ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
if err != nil {
return nil, err
}
@@ -102,7 +103,7 @@ func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime
filter["owner_ids"] = ownerIDs
}
participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
if err != nil {
return nil, err
}
@@ -231,26 +232,26 @@ var MinutesSearch = common.Shortcut{
return err
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
return output.ErrValidation("--query: length must be between 1 and 50 characters")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query: length must be between 1 and 50 characters").WithParam("--query")
}
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
return err
}
ownerIDs, err := common.ResolveOpenIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
ownerIDs, err := common.ResolveOpenIDsTyped("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
if err != nil {
return err
}
for _, id := range ownerIDs {
if _, err := common.ValidateUserID(id); err != nil {
if _, err := common.ValidateUserIDTyped("--owner-ids", id); err != nil {
return err
}
}
participantIDs, err := common.ResolveOpenIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
participantIDs, err := common.ResolveOpenIDsTyped("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
if err != nil {
return err
}
for _, id := range participantIDs {
if _, err := common.ValidateUserID(id); err != nil {
if _, err := common.ValidateUserIDTyped("--participant-ids", id); err != nil {
return err
}
}
@@ -259,7 +260,7 @@ var MinutesSearch = common.Shortcut{
return nil
}
}
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseTimeRange(runtime)
@@ -288,7 +289,7 @@ var MinutesSearch = common.Shortcut{
return err
}
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
data, err := runtime.CallAPITyped(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
if err != nil {
return err
}

View File

@@ -6,9 +6,11 @@ package minutes
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
@@ -63,10 +65,11 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
start string
end string
wantMessage string
wantParam string
}{
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
{name: "invalid start", start: "bad-start", wantMessage: "--start:", wantParam: "--start"},
{name: "invalid end", end: "bad-end", wantMessage: "--end:", wantParam: "--end"},
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end", wantParam: "--start"},
}
for _, tt := range tests {
@@ -88,6 +91,16 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != tt.wantParam {
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
}
})
}
}
@@ -209,6 +222,16 @@ func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
if !strings.Contains(err.Error(), "resolvable open_id") {
t.Fatalf("unexpected error: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != "--"+tt.flag {
t.Fatalf("Param = %q, want --%s", ve.Param, tt.flag)
}
})
}
}
@@ -267,6 +290,13 @@ func TestMinutesSearchValidationNoFilter(t *testing.T) {
if !strings.Contains(err.Error(), "specify at least one") {
t.Fatalf("unexpected error: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
}
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
@@ -279,6 +309,16 @@ func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
if err == nil {
t.Fatal("expected invalid user ID error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != "--participant-ids" {
t.Fatalf("Param = %q, want --participant-ids", ve.Param)
}
}
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
@@ -291,6 +331,16 @@ func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
if err == nil {
t.Fatal("expected invalid owner ID error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != "--owner-ids" {
t.Fatalf("Param = %q, want --owner-ids", ve.Param)
}
}
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
@@ -306,6 +356,16 @@ func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
t.Fatalf("unexpected error: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != "--query" {
t.Fatalf("Param = %q, want --query", ve.Param)
}
}
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
@@ -335,6 +395,16 @@ func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
if !strings.Contains(err.Error(), "--page-size") {
t.Fatalf("unexpected error: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != "--page-size" {
t.Fatalf("Param = %q, want --page-size", ve.Param)
}
}
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
@@ -372,6 +442,13 @@ func TestMinutesSearchValidationTimeErrors(t *testing.T) {
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
})
}
}

View File

@@ -5,12 +5,11 @@ package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,27 +36,27 @@ var MinutesSpeakerReplace = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return output.ErrValidation("--minute-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
}
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID == "" {
return output.ErrValidation("--from-user-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id")
}
if _, err := common.ValidateUserID(fromUserID); err != nil {
return output.ErrValidation("--from-user-id: %s", err)
if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil {
return err
}
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
if toUserID == "" {
return output.ErrValidation("--to-user-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--to-user-id is required").WithParam("--to-user-id")
}
if _, err := common.ValidateUserID(toUserID); err != nil {
return output.ErrValidation("--to-user-id: %s", err)
if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil {
return err
}
if fromUserID == toUserID {
return output.ErrValidation("--from-user-id and --to-user-id must be different")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id")
}
return nil
},
@@ -84,7 +83,7 @@ var MinutesSpeakerReplace = common.Shortcut{
"to_user_id": toUserID,
}
_, err := runtime.CallAPI(http.MethodPut,
_, err := runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
@@ -103,37 +102,18 @@ var MinutesSpeakerReplace = common.Shortcut{
}
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
p, ok := errs.ProblemOf(err)
if !ok {
return err
}
switch exitErr.Detail.Code {
switch p.Code {
case minutesSpeakerReplaceNoEditPermission:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesSpeakerReplaceNoEditPermission,
Message: fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot replace the transcript speaker.", minuteToken)
p.Hint = "Ask the minute owner for minute edit permission"
case minutesSpeakerReplaceSpeakerNotFoundCode:
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "speaker_not_found",
Code: minutesSpeakerReplaceSpeakerNotFoundCode,
Message: fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID),
Hint: "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry.",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
p.Subtype = errs.SubtypeNotFound
p.Message = fmt.Sprintf("Speaker not found in minute %q: --from-user-id %q does not match an existing speaker in the transcript.", minuteToken, fromUserID)
p.Hint = "Check --minute-token and --from-user-id. Use an open_id for a speaker that appears in the minute transcript, then retry."
}
return err
}

View File

@@ -10,9 +10,9 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -44,12 +44,12 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
{
name: "invalid from prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
wantErr: "--from-user-id",
wantErr: "invalid user ID format",
},
{
name: "invalid to prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
wantErr: "--to-user-id",
wantErr: "invalid user ID format",
},
{
name: "from equals to",
@@ -76,6 +76,52 @@ func TestMinutesSpeakerReplace_Validate(t *testing.T) {
}
}
func TestMinutesSpeakerReplace_ValidateTyped(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
tests := []struct {
name string
args []string
wantParam string
}{
{
name: "invalid from prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "u_a", "--to-user-id", "ou_b", "--as", "user"},
wantParam: "--from-user-id",
},
{
name: "from equals to",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_same", "--to-user-id", "ou_same", "--as", "user"},
wantParam: "--to-user-id",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "minutes"}
MinutesSpeakerReplace.Mount(parent, f)
parent.SetArgs(tt.args)
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != tt.wantParam {
t.Errorf("param=%q, want %q", ve.Param, tt.wantParam)
}
})
}
}
func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
@@ -179,24 +225,21 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
t.Fatal("expected speaker-not-found error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed errs.*, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
if p.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeNotFound)
}
if exitErr.Detail.Type != "speaker_not_found" {
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
if !strings.Contains(p.Message, "Speaker not found") {
t.Errorf("message should be friendly, got: %s", p.Message)
}
if !strings.Contains(exitErr.Detail.Message, "Speaker not found") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
if !strings.Contains(p.Message, "ou_missing_speaker") {
t.Errorf("message should include missing speaker id, got: %s", p.Message)
}
if !strings.Contains(exitErr.Detail.Message, "ou_missing_speaker") {
t.Errorf("message should include missing speaker id, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--from-user-id") {
t.Errorf("hint should mention --from-user-id, got: %s", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "--from-user-id") {
t.Errorf("hint should mention --from-user-id, got: %s", p.Hint)
}
}
@@ -225,23 +268,20 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
t.Fatal("expected no-edit-permission error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed errs.*, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
if p.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
}
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
if !strings.Contains(p.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", p.Message)
}
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) {
t.Errorf("message should include minute token, got: %s", p.Message)
}
if !strings.Contains(exitErr.Detail.Message, minutesSpeakerReplaceTestToken) {
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
}
}

View File

@@ -5,12 +5,11 @@ package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -33,13 +32,13 @@ var MinutesUpdate = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return output.ErrValidation("--minute-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
}
if strings.TrimSpace(runtime.Str("topic")) == "" {
return output.ErrValidation("--topic is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--topic is required").WithParam("--topic")
}
return nil
},
@@ -57,7 +56,7 @@ var MinutesUpdate = common.Shortcut{
"topic": topic,
}
_, err := runtime.CallAPI(http.MethodPatch,
_, err := runtime.CallAPITyped(http.MethodPatch,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
@@ -75,20 +74,11 @@ var MinutesUpdate = common.Shortcut{
}
func minutesUpdateError(err error, minuteToken string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesUpdateNoEditPermissionCode {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != minutesUpdateNoEditPermissionCode {
return err
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_edit_permission",
Code: minutesUpdateNoEditPermissionCode,
Message: fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken),
Hint: "Ask the minute owner for minute edit permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
p.Message = fmt.Sprintf("No edit permission for minute %q: cannot update the title.", minuteToken)
p.Hint = "Ask the minute owner for minute edit permission"
return err
}

View File

@@ -9,9 +9,9 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -55,6 +55,32 @@ func TestMinutesUpdate_Validate(t *testing.T) {
}
}
func TestMinutesUpdate_ValidateTyped(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
// ".." triggers ResourceName rejection — hits our Validate, not cobra's required-flag check.
parent := &cobra.Command{Use: "minutes"}
MinutesUpdate.Mount(parent, f)
parent.SetArgs([]string{"+update", "--minute-token", "..", "--topic", "title", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != "--minute-token" {
t.Errorf("param=%q", ve.Param)
}
}
func TestMinutesUpdate_DryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
@@ -132,23 +158,20 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) {
t.Fatal("expected no-edit-permission error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed errs.*, got %T: %v", err, err)
}
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
if p.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
}
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
if !strings.Contains(p.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", p.Message)
}
if !strings.Contains(exitErr.Detail.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
if !strings.Contains(p.Message, minutesUpdateTestToken) {
t.Errorf("message should include minute token, got: %s", p.Message)
}
if !strings.Contains(exitErr.Detail.Message, minutesUpdateTestToken) {
t.Errorf("message should include minute token, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", exitErr.Detail.Hint)
if !strings.Contains(p.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
}
}

View File

@@ -6,7 +6,7 @@ package minutes
import (
"context"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,10 +36,10 @@ var MinutesUpload = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
fileToken := runtime.Str("file-token")
if fileToken == "" {
return output.ErrValidation("--file-token is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required").WithParam("--file-token")
}
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
}
return nil
},
@@ -55,7 +55,7 @@ var MinutesUpload = common.Shortcut{
"file_token": fileToken,
}
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
if err != nil {
return err
}

View File

@@ -5,10 +5,12 @@ package minutes
import (
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
@@ -46,6 +48,31 @@ func TestMinutesUpload_Validate(t *testing.T) {
}
}
func TestMinutesUpload_ValidateTyped(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
// ".." triggers ResourceName rejection — hits our Validate, not cobra's required-flag check.
parent := &cobra.Command{Use: "minutes"}
MinutesUpload.Mount(parent, f)
parent.SetArgs([]string{"+upload", "--file-token", "..", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
err := parent.Execute()
if err == nil {
t.Fatalf("expected error, got nil")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype=%q", ve.Subtype)
}
if ve.Param != "--file-token" {
t.Errorf("param=%q", ve.Param)
}
}
func TestMinutesUpload_HelpMetadata(t *testing.T) {
if len(MinutesUpload.Flags) == 0 {
t.Fatal("expected file-token flag metadata")

View File

@@ -97,11 +97,13 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) {
}
func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) {
// Factory.Config may be nil in tests that pass a zero-value factory.
// Brand only — never decrypt the app secret at registration time (avoids a
// keychain read on every invocation). ConfigBrand may be nil in tests that
// pass a zero-value factory.
var brand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
brand = cfg.Brand
if f != nil && f.ConfigBrand != nil {
if b, ok := f.ConfigBrand(); ok {
brand = b
}
}

View File

@@ -20,6 +20,10 @@ func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory {
Config: func() (*core.CliConfig, error) {
return &core.CliConfig{Brand: brand}, nil
},
// Registration reads the brand via ConfigBrand (no secret decryption).
ConfigBrand: func() (core.LarkBrand, bool) {
return brand, true
},
}
}

View File

@@ -155,7 +155,7 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) {
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -166,121 +166,111 @@ func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T
if docsCmd == nil || docsCmd.Name() != "docs" {
t.Fatalf("docs command not mounted: %#v", docsCmd)
}
if docsCmd.Flags().Lookup("api-version") == nil {
t.Fatal("docs command should expose --api-version for versioned help")
if docsCmd.Flags().Lookup("api-version") != nil {
t.Fatal("docs command should not expose service-level --api-version")
}
if !strings.Contains(docsCmd.Long, "Document and content operations.") {
t.Fatalf("docs long help missing default description:\n%s", docsCmd.Long)
}
for _, child := range docsCmd.Commands() {
if child.Name() == "+get-skill" {
t.Fatal("docs +get-skill should not be mounted")
}
}
var defaultHelp bytes.Buffer
docsCmd.SetOut(&defaultHelp)
if err := docsCmd.Help(); err != nil {
t.Fatalf("docs help failed: %v", err)
}
for _, want := range []string{
"Tips:",
"Docs v1 is deprecated and will be removed soon",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
"Start here (required for AI agents):",
"lark-cli skills read lark-doc",
"AI agents MUST read the matching embedded skill",
"Do not skip this step",
"MUST NOT grep/open local SKILL.md files",
} {
if !strings.Contains(defaultHelp.String(), want) {
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.String())
}
}
}
func TestRegisterShortcutsDocsV2HelpUsesV2Description(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
docsCmd, _, err := program.Find([]string{"docs"})
if err != nil {
t.Fatalf("find docs command: %v", err)
}
if err := docsCmd.Flags().Set("api-version", "v2"); err != nil {
t.Fatalf("set docs api-version: %v", err)
}
var out bytes.Buffer
docsCmd.SetOut(&out)
if err := docsCmd.Help(); err != nil {
t.Fatalf("docs v2 help failed: %v", err)
}
for _, want := range []string{
"Document and content operations (v2).",
"Tips:",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs v2 help missing %q:\n%s", want, out.String())
}
if startIdx, usageIdx := strings.Index(defaultHelp.String(), "Start here (required for AI agents):"), strings.Index(defaultHelp.String(), "Usage:"); startIdx < 0 || usageIdx < 0 || startIdx > usageIdx {
t.Fatalf("docs help should show Start here before Usage:\n%s", defaultHelp.String())
}
for _, unwanted := range []string{
"Tips:",
"+get-skill",
"Docs shortcuts are v2-only",
"Docs v1 is deprecated and will be removed soon",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
"lark-cli update",
"upgrade skills",
"Use --api-version v2 for the latest API",
} {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String())
if strings.Contains(defaultHelp.String(), unwanted) {
t.Fatalf("docs help should not include %q:\n%s", unwanted, defaultHelp.String())
}
}
}
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
tests := []struct {
name string
shortcut string
apiVersion string
shortcutHelp string
versionedFlag string
name string
shortcut string
shortcutHelp string
visibleFlag string
skillCommand string
hiddenFlags []string
contentHelp []string
unwanted []string
}{
{
name: "create v1",
shortcut: "+create",
apiVersion: "v1",
shortcutHelp: "Create a Lark document",
versionedFlag: "--markdown",
name: "create",
shortcut: "+create",
shortcutHelp: "Create a Lark document",
visibleFlag: "--content",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-create.md",
hiddenFlags: []string{"title", "markdown", "folder-token", "wiki-node", "wiki-space"},
contentHelp: []string{
"AI agents MUST read",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"before writing any --content payload",
"when using --doc-format markdown, also read",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"Follow the latest rules",
"MUST NOT grep/open local SKILL.md files",
"use --help for the latest command flags",
},
unwanted: []string{"--markdown", "--title", "--folder-token", "--wiki-node", "--wiki-space"},
},
{
name: "create v2",
shortcut: "+create",
apiVersion: "v2",
shortcutHelp: "Create a Lark document",
versionedFlag: "--content",
name: "fetch",
shortcut: "+fetch",
shortcutHelp: "Fetch Lark document content",
visibleFlag: "read scope",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-fetch.md",
hiddenFlags: []string{"offset", "limit"},
unwanted: []string{"--offset", "--limit"},
},
{
name: "fetch v1",
shortcut: "+fetch",
apiVersion: "v1",
shortcutHelp: "Fetch Lark document content",
versionedFlag: "--offset",
},
{
name: "fetch v2",
shortcut: "+fetch",
apiVersion: "v2",
shortcutHelp: "Fetch Lark document content",
versionedFlag: "partial read scope",
},
{
name: "update v1",
shortcut: "+update",
apiVersion: "v1",
shortcutHelp: "Update a Lark document",
versionedFlag: "--mode",
},
{
name: "update v2",
shortcut: "+update",
apiVersion: "v2",
shortcutHelp: "Update a Lark document",
versionedFlag: "--command",
name: "update",
shortcut: "+update",
shortcutHelp: "Update a Lark document",
visibleFlag: "--command",
skillCommand: "lark-cli skills read lark-doc references/lark-doc-update.md",
hiddenFlags: []string{"mode", "markdown", "selection-with-ellipsis", "selection-by-title", "new-title"},
contentHelp: []string{
"AI agents MUST read",
"lark-cli skills read lark-doc references/lark-doc-xml.md",
"before writing any --content payload",
"when using --doc-format markdown, also read",
"lark-cli skills read lark-doc references/lark-doc-md.md",
"Follow the latest rules",
"MUST NOT grep/open local SKILL.md files",
"use --help for the latest command flags",
},
unwanted: []string{"--mode", "--markdown", "--selection-with-ellipsis", "--selection-by-title", "--new-title"},
},
}
@@ -296,8 +286,25 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
if cmd == nil || cmd.Name() != tt.shortcut {
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
}
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
for _, flagName := range tt.hiddenFlags {
flag := cmd.Flags().Lookup(flagName)
if flag == nil {
t.Fatalf("docs %s missing hidden compatibility flag %q", tt.shortcut, flagName)
}
if !flag.Hidden {
t.Fatalf("docs %s flag %q should be hidden", tt.shortcut, flagName)
}
}
apiVersionFlag := cmd.Flags().Lookup("api-version")
if apiVersionFlag == nil {
t.Fatalf("docs %s missing --api-version flag", tt.shortcut)
}
if apiVersionFlag.Hidden {
t.Fatalf("docs %s --api-version should be visible", tt.shortcut)
}
if apiVersionFlag.DefValue != "v2" {
t.Fatalf("docs %s --api-version default = %q, want v2", tt.shortcut, apiVersionFlag.DefValue)
}
var out bytes.Buffer
@@ -306,49 +313,39 @@ func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T)
t.Fatalf("docs %s help failed: %v", tt.shortcut, err)
}
wantTips := []string{
"Tips:",
"Docs v1 is deprecated and will be removed soon",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
}
unwantedTips := []string{
"[NOTE]",
"Use --api-version v2 for the latest API",
"otherwise use the default v1 flags",
"legacy v1 examples and flags",
}
if tt.apiVersion == "v2" {
wantTips = []string{
"Tips:",
"Check the installed lark-doc skill first",
"if it is not the v2 skill, run `lark-cli update` to upgrade skills",
}
unwantedTips = append(unwantedTips,
"Docs v1 is deprecated and will be removed soon",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
)
}
for _, want := range []string{
tt.shortcutHelp,
tt.versionedFlag,
tt.visibleFlag,
"--api-version",
"deprecated compatibility flag; docs shortcuts always use v2",
"both v1/v2 are accepted",
"(default \"v2\")",
"Start here (required for AI agents):",
"AI agents MUST read the matching embedded skill",
"Do not skip this step",
"MUST NOT grep/open local SKILL.md files",
tt.skillCommand,
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
t.Fatalf("docs %s help missing %q:\n%s", tt.shortcut, want, out.String())
}
}
for _, want := range wantTips {
for _, want := range tt.contentHelp {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
t.Fatalf("docs %s content help missing %q:\n%s", tt.shortcut, want, out.String())
}
}
for _, unwanted := range unwantedTips {
if startIdx, usageIdx := strings.Index(out.String(), "Start here (required for AI agents):"), strings.Index(out.String(), "Usage:"); startIdx < 0 || usageIdx < 0 || startIdx > usageIdx {
t.Fatalf("docs %s help should show Start here before Usage:\n%s", tt.shortcut, out.String())
}
for _, unwanted := range []string{"Tips:", "+get-skill", "Docs shortcuts are v2-only"} {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
t.Fatalf("docs %s help should not include %q:\n%s", tt.shortcut, unwanted, out.String())
}
}
for _, unwanted := range tt.unwanted {
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs %s help should not include %q:\n%s", tt.shortcut, unwanted, out.String())
}
}
})

View File

@@ -14,8 +14,7 @@ import (
"time"
"unicode"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,7 +36,7 @@ func toUnixSeconds(input string, hint ...string) (string, error) {
return "", err
}
if _, err := strconv.ParseInt(ts, 10, 64); err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err) //nolint:forbidigo // intermediate parse error; callers wrap it into a typed ValidationError
}
return ts, nil
}
@@ -134,7 +133,7 @@ func meetingEventsPageSize(runtime *common.RuntimeContext) (int, error) {
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
return 0, common.FlagErrorf("invalid --page-size %q: must be an integer", pageSizeStr)
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %q: must be an integer", pageSizeStr).WithParam("--page-size")
}
if pageSize < minVCMeetingEventsPageSize {
return minVCMeetingEventsPageSize, nil
@@ -155,11 +154,11 @@ func meetingEventsPaginationConfig(runtime *common.RuntimeContext) (bool, int) {
func validateMeetingEventsMeetingID(meetingID string) error {
meetingID = strings.TrimSpace(meetingID)
if meetingID == "" {
return common.FlagErrorf("--meeting-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id is required").WithParam("--meeting-id")
}
value, err := strconv.ParseInt(meetingID, 10, 64)
if err != nil || value <= 0 {
return common.FlagErrorf("--meeting-id must be a positive integer, got %q", meetingID)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id must be a positive integer, got %q", meetingID).WithParam("--meeting-id")
}
return nil
}
@@ -176,14 +175,14 @@ func parseMeetingEventsTimeRange(runtime *common.RuntimeContext) (string, string
if start != "" {
parsed, err := toUnixSeconds(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
}
startTime = parsed
}
if end != "" {
parsed, err := toUnixSeconds(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
endTime = parsed
}
@@ -191,29 +190,30 @@ func parseMeetingEventsTimeRange(runtime *common.RuntimeContext) (string, string
startValue, _ := strconv.ParseInt(startTime, 10, 64)
endValue, _ := strconv.ParseInt(endTime, 10, 64)
if startValue > endValue {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start (%s) is after --end (%s)", start, end).WithParam("--start")
}
}
return startTime, endTime, nil
}
func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (larkcore.QueryParams, error) {
func buildMeetingEventsParams(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
pageSize, err := meetingEventsPageSize(runtime)
if err != nil {
return nil, err
}
params := make(larkcore.QueryParams)
params.Set("meeting_id", strings.TrimSpace(runtime.Str("meeting-id")))
params.Set("page_size", strconv.Itoa(pageSize))
params := map[string]interface{}{
"meeting_id": strings.TrimSpace(runtime.Str("meeting-id")),
"page_size": strconv.Itoa(pageSize),
}
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
params.Set("page_token", pageToken)
params["page_token"] = pageToken
}
if startTime != "" {
params.Set("start_time", startTime)
params["start_time"] = startTime
}
if endTime != "" {
params.Set("end_time", endTime)
params["end_time"] = endTime
}
return params, nil
}
@@ -225,7 +225,7 @@ func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, sta
}
autoPaginate, pageLimit := meetingEventsPaginationConfig(runtime)
if !autoPaginate {
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
if err != nil {
return nil, nil, false, "", err
}
@@ -245,7 +245,7 @@ func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, sta
lastHasMore bool
)
for page := 0; page < pageLimit; page++ {
data, err := runtime.DoAPIJSON(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingEventsAPIPath, params, nil)
if err != nil {
return nil, nil, false, "", err
}
@@ -260,7 +260,7 @@ func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, sta
if !lastHasMore || lastPageToken == "" {
break
}
params.Set("page_token", lastPageToken)
params["page_token"] = lastPageToken
}
if lastData == nil {
lastData = map[string]interface{}{}
@@ -271,24 +271,11 @@ func fetchMeetingEvents(ctx context.Context, runtime *common.RuntimeContext, sta
return lastData, allEvents, lastHasMore, lastPageToken, nil
}
func flattenQueryParams(params larkcore.QueryParams) map[string]interface{} {
func flattenQueryParams(params map[string]interface{}) map[string]interface{} {
if len(params) == 0 {
return nil
}
flat := make(map[string]interface{}, len(params))
for key, values := range params {
switch len(values) {
case 0:
continue
case 1:
flat[key] = values[0]
default:
copied := make([]string, len(values))
copy(copied, values)
flat[key] = copied
}
}
return flat
return params
}
func compactMeetingEvents(events []interface{}) []interface{} {

View File

@@ -5,6 +5,7 @@ package vc
import (
"context"
"errors"
"reflect"
"strings"
"testing"
@@ -12,10 +13,10 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
func newMeetingEventsRuntime() *common.RuntimeContext {
@@ -323,19 +324,19 @@ func TestBuildMeetingEventsParams(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["meeting_id"][0]; got != "7628568141510692381" {
if got := params["meeting_id"]; got != "7628568141510692381" {
t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381")
}
if got := params["page_size"][0]; got != "40" {
if got := params["page_size"]; got != "40" {
t.Fatalf("page_size = %q, want %q", got, "40")
}
if got := params["page_token"][0]; got != "1710000000000000000" {
if got := params["page_token"]; got != "1710000000000000000" {
t.Fatalf("page_token = %q, want %q", got, "1710000000000000000")
}
if got := params["start_time"][0]; got != "1710000000" {
if got := params["start_time"]; got != "1710000000" {
t.Fatalf("start_time = %q, want %q", got, "1710000000")
}
if got := params["end_time"][0]; got != "1710003600" {
if got := params["end_time"]; got != "1710003600" {
t.Fatalf("end_time = %q, want %q", got, "1710003600")
}
}
@@ -349,7 +350,7 @@ func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "20" {
if got := params["page_size"]; got != "20" {
t.Fatalf("page_size = %q, want %q when below min", got, "20")
}
}
@@ -363,7 +364,7 @@ func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "100" {
if got := params["page_size"]; got != "100" {
t.Fatalf("page_size = %q, want %q when above max", got, "100")
}
}
@@ -378,7 +379,7 @@ func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"][0]; got != "100" {
if got := params["page_size"]; got != "100" {
t.Fatalf("page_size = %q, want %q when page-all is set", got, "100")
}
}
@@ -746,22 +747,30 @@ func TestFormatTimelineOffset(t *testing.T) {
}
func TestFlattenQueryParams(t *testing.T) {
params := larkcore.QueryParams{
"one": []string{"1"},
"many": []string{"2", "3"},
"empty": []string{},
params := map[string]interface{}{
"one": "1",
"many": "2",
}
got := flattenQueryParams(params)
want := map[string]interface{}{
"one": "1",
"many": []string{"2", "3"},
"many": "2",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("flattenQueryParams() = %#v, want %#v", got, want)
}
}
func TestFlattenQueryParams_NilOnEmpty(t *testing.T) {
if got := flattenQueryParams(nil); got != nil {
t.Fatalf("flattenQueryParams(nil) = %#v, want nil", got)
}
if got := flattenQueryParams(map[string]interface{}{}); got != nil {
t.Fatalf("flattenQueryParams(empty) = %#v, want nil", got)
}
}
func TestCompactMeetingPayload_DropsOnlyEmptySlices(t *testing.T) {
got := compactMeetingPayload(map[string]interface{}{
"empty_items": []interface{}{},
@@ -929,3 +938,79 @@ func TestNeedsColon(t *testing.T) {
}
}
}
// ---------------------------------------------------------------------------
// Typed error lock assertions
// ---------------------------------------------------------------------------
func TestMeetingEvents_Validation_InvalidMeetingID_TypedError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "not-a-number")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "positive integer") {
t.Errorf("message mismatch: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--meeting-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-id")
}
}
func TestMeetingEvents_Validation_InvalidPageSize_TypedError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "page-size", "foo")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "invalid --page-size") {
t.Errorf("message mismatch: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--page-size" {
t.Errorf("Param = %q, want %q", ve.Param, "--page-size")
}
}
func TestMeetingEvents_Validation_StartAfterEnd_TypedError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
mustSetMeetingEventsFlag(t, runtime, "start", "200")
mustSetMeetingEventsFlag(t, runtime, "end", "100")
err := VCMeetingEvents.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "after --end") {
t.Errorf("message mismatch: %v", err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--start" {
t.Errorf("Param = %q, want %q", ve.Param, "--start")
}
}

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -37,7 +38,7 @@ var VCMeetingJoin = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
mn := strings.TrimSpace(runtime.Str("meeting-number"))
if !validMeetingNumber(mn) {
return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-number must be exactly 9 digits, got %q", mn).WithParam("--meeting-number")
}
return nil
},
@@ -49,7 +50,7 @@ var VCMeetingJoin = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildMeetingJoinBody(runtime)
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/vc/v1/bots/join", nil, body)
if err != nil {
return err
}

View File

@@ -9,6 +9,7 @@ import (
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -26,7 +27,7 @@ var VCMeetingLeave = common.Shortcut{
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("meeting-id")) == "" {
return common.FlagErrorf("--meeting-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-id is required").WithParam("--meeting-id")
}
return nil
},
@@ -42,7 +43,7 @@ var VCMeetingLeave = common.Shortcut{
body := map[string]interface{}{
"meeting_id": meetingID,
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/leave", nil, body)
data, err := runtime.CallAPITyped("POST", "/open-apis/vc/v1/bots/leave", nil, body)
if err != nil {
return err
}

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