Compare commits

...

18 Commits

Author SHA1 Message Date
wangweiming
21b81f7ae4 fix: update ppe request headers
Change-Id: I7ea680f3f553646b41b0591b701eb14057f6c11d
2026-06-09 21:19:40 +08:00
wangweiming
5eaeab2bb3 feat: add PPE headers and log IDs
Inject PPE request headers into base CLI headers, log OpenAPI response log IDs, and point Feishu endpoints at the pre environment for this branch.

Change-Id: Id8a5328796ab8cdfa25147daace976e2fa242017
2026-06-08 21:41:57 +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
110 changed files with 48025 additions and 4228 deletions

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

@@ -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

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

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

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

View File

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

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"io"
"io/fs"
"net/http"
"strings"
@@ -43,6 +44,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.

View File

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

View File

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

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

@@ -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

@@ -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

@@ -424,6 +424,8 @@ func TestFeedGroupValidationErrors(t *testing.T) {
{"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)"},
@@ -446,7 +448,7 @@ func TestFeedGroupValidationErrors(t *testing.T) {
func TestFeedGroupListItemDryRun(t *testing.T) {
runtime := newFGRuntime(t, ImFeedGroupListItem, map[string]string{
"feed-group-id": "ofg_x", "page-size": "10", "start-time": "100",
"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)
@@ -460,11 +462,12 @@ func TestFeedGroupListItemDryRun(t *testing.T) {
t.Errorf("url = %s", url)
}
params, _ := calls[0]["params"].(map[string]interface{})
if params["page_size"] != "10" {
t.Errorf("params page_size = %v, want 10", params["page_size"])
}
if params["start_time"] != "100" {
t.Errorf("params start_time = %v, want 100", params["start_time"])
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)
@@ -514,3 +517,197 @@ func TestFeedGroupQueryItemDryRunValidationError(t *testing.T) {
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

@@ -9,6 +9,7 @@ import (
"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"
@@ -57,7 +58,7 @@ var ImFeedGroupList = common.Shortcut{
return executeFeedGroupListGroupsAllPages(runtime)
}
data, err := runtime.DoAPIJSON("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListPath, feedGroupListGroupsQuery(runtime), nil)
if err != nil {
return err
}
@@ -72,19 +73,19 @@ var ImFeedGroupList = common.Shortcut{
func validateFeedGroupListPageOptions(rt *common.RuntimeContext) error {
if n := rt.Int("page-size"); n < 1 || n > 50 {
return output.ErrValidation("--page-size must be an integer between 1 and 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 output.ErrValidation("--page-limit must be an integer between 1 and 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 output.ErrValidation("--start-time must be Unix milliseconds (a decimal integer string)")
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 output.ErrValidation("--end-time must be Unix milliseconds (a decimal integer string)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-time must be Unix milliseconds (a decimal integer string)").WithParam("--end-time")
}
}
return nil
@@ -158,7 +159,7 @@ func executeFeedGroupListGroupsAllPages(rt *common.RuntimeContext) error {
params["end_time"] = []string{end}
}
data, err := rt.DoAPIJSON("GET", feedGroupListPath, params, nil)
data, err := rt.DoAPIJSONTyped("GET", feedGroupListPath, params, nil)
if err != nil {
return err
}

View File

@@ -9,7 +9,7 @@ import (
"io"
"strconv"
"github.com/larksuite/cli/internal/output"
"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"
@@ -54,7 +54,7 @@ var ImFeedGroupListItem = common.Shortcut{
return executeFeedGroupListAllPages(runtime)
}
data, err := runtime.DoAPIJSON("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
data, err := runtime.DoAPIJSONTyped("GET", feedGroupListItemPath(runtime), feedGroupListQuery(runtime), nil)
if err != nil {
return err
}
@@ -70,22 +70,22 @@ var ImFeedGroupListItem = common.Shortcut{
func validateFeedGroupListOptions(rt *common.RuntimeContext) error {
if rt.Str("feed-group-id") == "" {
return output.ErrValidation("--feed-group-id is required")
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 output.ErrValidation("--page-size must be an integer between 1 and 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 output.ErrValidation("--page-limit must be an integer between 1 and 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 output.ErrValidation("--start-time must be Unix milliseconds (a decimal integer string)")
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 output.ErrValidation("--end-time must be Unix milliseconds (a decimal integer string)")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end-time must be Unix milliseconds (a decimal integer string)").WithParam("--end-time")
}
}
return nil
@@ -163,7 +163,7 @@ func executeFeedGroupListAllPages(rt *common.RuntimeContext) error {
params["end_time"] = []string{end}
}
data, err := rt.DoAPIJSON("GET", feedGroupListItemPath(rt), params, nil)
data, err := rt.DoAPIJSONTyped("GET", feedGroupListItemPath(rt), params, nil)
if err != nil {
return err
}

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
)
@@ -102,6 +103,7 @@ func TestFeedGroupListValidation(t *testing.T) {
{"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) {
@@ -116,3 +118,140 @@ func TestFeedGroupListValidation(t *testing.T) {
})
}
}
// 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

@@ -7,7 +7,7 @@ import (
"context"
"io"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -47,7 +47,7 @@ var ImFeedGroupQueryItem = common.Shortcut{
return err
}
data, err := runtime.DoAPIJSON("POST", feedGroupQueryItemPath(runtime), nil, body)
data, err := runtime.DoAPIJSONTyped("POST", feedGroupQueryItemPath(runtime), nil, body)
if err != nil {
return err
}
@@ -70,7 +70,7 @@ func feedGroupQueryItemPath(rt *common.RuntimeContext) string {
// {"items":[{"feed_id":"<tok>","feed_type":"chat"}, ...]}.
func buildFeedGroupQueryItemBody(rt *common.RuntimeContext) (map[string]any, error) {
if rt.Str("feed-group-id") == "" {
return nil, output.ErrValidation("--feed-group-id is required")
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))
@@ -84,7 +84,7 @@ func buildFeedGroupQueryItemBody(rt *common.RuntimeContext) (map[string]any, err
})
}
if len(items) == 0 {
return nil, output.ErrValidation("--feed-id is required (comma-separated chat IDs)")
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

@@ -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

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

View File

@@ -7,11 +7,13 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"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"
@@ -577,7 +579,67 @@ func TestMeetingLeave_Execute_APIError(t *testing.T) {
if err == nil {
t.Fatal("expected error for API failure")
}
if !strings.Contains(err.Error(), "no permission") {
t.Errorf("error should surface API message, got: %v", err)
// code 121005 classifies to a typed permission error (no edit/view rights).
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
}
}
// ---------------------------------------------------------------------------
// Typed error lock assertions
// ---------------------------------------------------------------------------
func TestMeetingJoin_Validate_InvalidFormat_TypedError(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-number", "", "")
cmd.Flags().String("password", "", "")
_ = cmd.Flags().Set("meeting-number", "12345678") // 8 digits — invalid
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingJoin.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "9 digits") {
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-number" {
t.Errorf("Param = %q, want %q", ve.Param, "--meeting-number")
}
}
func TestMeetingLeave_Validate_WhitespaceOnly_TypedError(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("meeting-id", "", "")
_ = cmd.Flags().Set("meeting-id", " ")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCMeetingLeave.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "meeting-id") {
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")
}
}

View File

@@ -23,8 +23,6 @@ import (
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/auth"
@@ -74,22 +72,13 @@ const (
)
func minutesReadError(err error, minuteToken string) error {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesNoReadPermissionCode {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != minutesNoReadPermissionCode {
return err
}
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "no_read_permission",
Code: minutesNoReadPermissionCode,
Message: fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken),
Hint: "Ask the minute owner for minute file read permission",
Detail: exitErr.Detail.Detail,
},
Err: err,
}
p.Message = fmt.Sprintf("No read permission for minute %s: cannot query the minute.", minuteToken)
p.Hint = "Ask the minute owner for minute file read permission"
return err
}
// validMinuteToken matches the server's minute-token format and blocks any
@@ -114,19 +103,19 @@ func sanitizeLogValue(s string) string {
// getPrimaryCalendarID retrieves the current user's primary calendar ID.
func getPrimaryCalendarID(runtime *common.RuntimeContext) (string, error) {
data, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/calendar/v4/calendars/primary", nil, nil)
data, err := runtime.CallAPITyped(http.MethodPost, "/open-apis/calendar/v4/calendars/primary", nil, nil)
if err != nil {
return "", err // preserve original API error (with lark error code)
return "", err
}
calendars, _ := data["calendars"].([]any)
if len(calendars) == 0 {
return "", output.ErrValidation("primary calendar not found")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "primary calendar not found")
}
first, _ := calendars[0].(map[string]any)
cal, _ := first["calendar"].(map[string]any)
calID, _ := cal["calendar_id"].(string)
if calID == "" {
return "", output.ErrValidation("primary calendar ID is empty")
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "primary calendar ID is empty")
}
return calID, nil
}
@@ -149,23 +138,23 @@ func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instance
if needNotes {
body["need_meeting_notes"] = true
}
data, err := runtime.DoAPIJSON(http.MethodPost,
data, err := runtime.CallAPITyped(http.MethodPost,
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/mget_instance_relation_info", validate.EncodePathSegment(calendarID)),
nil,
body)
if err != nil {
return nil, fmt.Errorf("failed to query event relation info: %w", err)
return nil, err
}
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return nil, fmt.Errorf("no event relation info found")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "no event relation info found")
}
info, _ := infos[0].(map[string]any)
rawIDs, _ := info["meeting_instance_ids"].([]any)
if len(rawIDs) == 0 {
return nil, fmt.Errorf("no associated video meeting for this event")
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "no associated video meeting for this event")
}
result := &eventRelationInfo{}
@@ -293,13 +282,12 @@ func asStringSlice(v any) []string {
// Other failures fall back to the raw API error description so Agents can
// still parse the underlying cause.
func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (token, errMsg string) {
data, err := runtime.DoAPIJSON(http.MethodGet,
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
switch exitErr.Detail.Code {
if p, ok := errs.ProblemOf(err); ok {
switch p.Code {
case recordingNotFoundCode:
return "", "no minute file for this meeting"
case recordingNoPermissionCode:
@@ -328,8 +316,8 @@ func fetchMeetingMinuteToken(runtime *common.RuntimeContext, meetingID string) (
// (semicolon-separated) so Agents always see all causes at once. The
// `minute_token` field is only populated on success.
func fetchNoteByMeetingID(ctx context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
larkcore.QueryParams{"with_participants": []string{"false"}, "query_mode": []string{"0"}}, nil)
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
if err != nil {
return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query meeting: %v", err)}
}
@@ -399,13 +387,12 @@ func hasNotesPayload(m map[string]any) bool {
func fetchNoteByMinuteToken(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) map[string]any {
errOut := runtime.IO().ErrOut
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
err = minutesReadError(err, minuteToken)
result := map[string]any{"minute_token": minuteToken, "error": err.Error()}
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Hint != "" {
result["hint"] = exitErr.Detail.Hint
if p, ok := errs.ProblemOf(err); ok && p.Hint != "" {
result["hint"] = p.Hint
}
return result
}
@@ -469,7 +456,7 @@ func sanitizeDirName(title, minuteToken string) string {
func fetchInlineArtifacts(runtime *common.RuntimeContext, minuteToken string, title string, result map[string]any) {
errOut := runtime.IO().ErrOut
fmt.Fprintf(errOut, "%s fetching AI artifacts...\n", logPrefix)
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
if err != nil {
fmt.Fprintf(errOut, "%s failed to fetch AI artifacts: %v\n", logPrefix, err)
return
@@ -582,11 +569,10 @@ func extractDocTokens(refs []any) []string {
// fetchNoteDetail retrieves note document tokens via note_id.
func fetchNoteDetail(_ context.Context, runtime *common.RuntimeContext, noteID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Code == noteNoPermissionCode {
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", exitErr.Detail.Code)}
if p, ok := errs.ProblemOf(err); ok && p.Code == noteNoPermissionCode {
return map[string]any{"error": fmt.Sprintf("[%v]: no read permission for this meeting note", p.Code)}
}
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
}
@@ -630,7 +616,7 @@ var VCNotes = common.Shortcut{
{Name: "overwrite", Type: "bool", Desc: "overwrite existing artifact files"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOne(runtime, "meeting-ids", "minute-tokens", "calendar-event-ids"); err != nil {
if err := common.ExactlyOneTyped(runtime, "meeting-ids", "minute-tokens", "calendar-event-ids"); err != nil {
return err
}
// batch input size limit
@@ -638,12 +624,12 @@ var VCNotes = common.Shortcut{
for _, flag := range []string{"meeting-ids", "minute-tokens", "calendar-event-ids"} {
if v := runtime.Str(flag); v != "" {
if ids := common.SplitCSV(v); len(ids) > maxBatchSize {
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize).WithParam("--" + flag)
}
}
}
if outDir := runtime.Str("output-dir"); outDir != "" {
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
return err
}
}
@@ -651,7 +637,7 @@ var VCNotes = common.Shortcut{
if v := runtime.Str("minute-tokens"); v != "" {
for _, token := range common.SplitCSV(v) {
if !validMinuteToken.MatchString(token) {
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters", token)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
}
}
}
@@ -772,9 +758,7 @@ var VCNotes = common.Shortcut{
// all failed → return structured error
if successCount == 0 && len(results) > 0 {
outData := map[string]any{"notes": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil)
return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil)
return runtime.OutPartialFailure(map[string]any{"notes": results}, &output.Meta{Count: len(results)})
}
// output

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -16,9 +17,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"
)
@@ -249,11 +252,25 @@ func TestNotes_Validation_ExactlyOne(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for no flags")
}
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.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
err = mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m1", "--minute-tokens", "t1", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for two flags")
}
ve = nil
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
}
func TestNotes_DryRun_MeetingIDs(t *testing.T) {
@@ -1006,11 +1023,18 @@ func TestNotes_MeetingPath_BothFail_ErrorJoinedWithSemicolon(t *testing.T) {
reg.Register(meetingGetStub("m_bothfail", ""))
reg.Register(recordingErrStub("m_bothfail", 121004, "data not found"))
// Two-path failure with no payload should make the batch return ErrAPI.
// Two-path failure with no payload should make the batch return OutPartialFailure.
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_bothfail", "--as", "user"}, f, stdout)
if err == nil {
t.Fatalf("expected batch failure error, got nil")
}
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 ExitAPI (%d)", pfErr.Code, output.ExitAPI)
}
note := extractFirstNote(t, stdout)
assertNoteFieldAbsent(t, note, "minute_token")
@@ -1044,6 +1068,13 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
if err == nil {
t.Fatalf("expected batch failure error, got nil")
}
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 ExitAPI (%d)", pfErr.Code, output.ExitAPI)
}
note := extractFirstNote(t, stdout)
assertNoteFieldAbsent(t, note, "note_doc_token", "minute_token")
@@ -1053,3 +1084,249 @@ func TestNotes_MeetingPath_NoteNoPermission_FriendlyHint(t *testing.T) {
"; ", // note + minute causes joined with semicolon
)
}
// ---------------------------------------------------------------------------
// Typed-error lock: errs.ValidationError assertions
// ---------------------------------------------------------------------------
func TestNotes_BatchLimit_TypedValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
ids := make([]string, 51)
for i := range ids {
ids[i] = fmt.Sprintf("m%d", i)
}
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", strings.Join(ids, ","), "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected batch limit error")
}
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 SubtypeInvalidArgument", ve.Subtype)
}
if !strings.HasPrefix(ve.Param, "--") {
t.Errorf("Param = %q, want prefix '--'", ve.Param)
}
}
func TestNotes_InvalidMinuteToken_TypedValidationError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCNotes, []string{"+notes", "--minute-tokens", "INVALID_TOKEN!", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for invalid minute token")
}
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 SubtypeInvalidArgument", ve.Subtype)
}
if ve.Param != "--minute-tokens" {
t.Errorf("Param = %q, want --minute-tokens", ve.Param)
}
}
func TestResolveMeetingIDs_NoRelationInfo_TypedValidationError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// mget returns empty instance_relation_infos
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_x/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{},
},
},
})
if err := botExec(t, "no-rel-info", f, func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_x", "cal_x", false)
if err == nil {
t.Fatal("expected error for empty instance_relation_infos")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("Subtype = %q, want SubtypeFailedPrecondition", ve.Subtype)
}
if !strings.Contains(ve.Error(), "no event relation info found") {
t.Errorf("message = %q, want contains 'no event relation info found'", ve.Error())
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestResolveMeetingIDs_NoMeetingIDs_TypedValidationError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
// mget returns one info entry but with no meeting_instance_ids
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_y/events/mget_instance_relation_info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"instance_relation_infos": []interface{}{
map[string]interface{}{
"instance_id": "evt_y",
"meeting_instance_ids": []interface{}{},
},
},
},
},
})
if err := botExec(t, "no-meeting-ids", f, func(_ context.Context, rctx *common.RuntimeContext) error {
_, err := resolveMeetingIDsFromCalendarEvent(rctx, "evt_y", "cal_y", false)
if err == nil {
t.Fatal("expected error for empty meeting_instance_ids")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("Subtype = %q, want SubtypeFailedPrecondition", ve.Subtype)
}
if !strings.Contains(ve.Error(), "no associated video meeting for this event") {
t.Errorf("message = %q, want contains 'no associated video meeting for this event'", ve.Error())
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Typed-error lock: enrichment via errs.ProblemOf
// ---------------------------------------------------------------------------
// minuteGetErrStub returns an error stub for the minutes API.
func minuteGetErrStub(token string, code int, msg string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/" + token,
Body: map[string]any{"code": code, "msg": msg},
}
}
// TestMinutesReadError_ProblemOf_EnrichesMessage pins that minutesReadError
// mutates the typed error's Message and Hint in-place via errs.ProblemOf when
// the server returns code 2091005 (minutes no-read-permission).
func TestMinutesReadError_ProblemOf_EnrichesMessage(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(minuteGetErrStub("tokperm", minutesNoReadPermissionCode, "no permission"))
// artifactsStub not needed: we never reach it on error
// A single minute-token that fails on a no-read-permission code still
// produces a note carrying minute_token, so the batch exits 0 with the
// enriched error surfaced inline rather than becoming an all-fail.
if err := mountAndRun(t, VCNotes, []string{"+notes", "--minute-tokens", "tokperm", "--as", "user"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// stdout carries the note with the enriched error/hint
var resp map[string]any
if parseErr := json.Unmarshal(stdout.Bytes(), &resp); parseErr != nil {
t.Fatalf("unmarshal stdout: %v\n%s", parseErr, stdout.String())
}
data, _ := resp["data"].(map[string]any)
notes, _ := data["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("expected 1 note, got %d", len(notes))
}
note, _ := notes[0].(map[string]any)
errMsg, _ := note["error"].(string)
if !strings.Contains(errMsg, "No read permission for minute tokperm") {
t.Errorf("error message not enriched: %q", errMsg)
}
hint, _ := note["hint"].(string)
if !strings.Contains(hint, "minute file read permission") {
t.Errorf("hint not surfaced: %q", hint)
}
}
// TestFetchNoteDetail_NoteNoPermission_ProblemOf pins that fetchNoteDetail
// returns a friendly error map when CallAPITyped returns code 121005 and
// ProblemOf can extract it.
func TestFetchNoteDetail_NoteNoPermission_ProblemOf(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// meeting.get returns note_id, note detail returns 121005
reg.Register(meetingGetStub("m_noteperm2", "note_perm2"))
reg.Register(noteDetailErrStub("note_perm2", noteNoPermissionCode, "no permission"))
reg.Register(recordingOKStub("m_noteperm2", "https://meetings.feishu.cn/minutes/obcpermtest"))
// note fails but minute_token succeeds → partial success (hasNotesPayload=true)
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_noteperm2", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error (expected partial success): %v", err)
}
note := extractFirstNote(t, stdout)
// minute_token succeeded so hasNotesPayload=true; note error still surfaced
if got := note["minute_token"]; got != "obcpermtest" {
t.Errorf("minute_token = %v, want obcpermtest", got)
}
errMsg, _ := note["error"].(string)
if !strings.Contains(errMsg, "[121005]") || !strings.Contains(errMsg, "no read permission for this meeting note") {
t.Errorf("fetchNoteDetail permission error = %q; want contains '[121005]: no read permission for this meeting note'", errMsg)
}
}
// TestNotes_AllFailed_OutPartialFailure pins that when every item in the batch
// fails (successCount == 0), Execute returns *output.PartialFailureError with
// ExitAPI code, and stdout still carries the ok:false envelope with notes data.
func TestNotes_AllFailed_OutPartialFailure(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// Both meetings have no note_id and recording returns 121004 (no minute file)
// → hasNotesPayload == false for both → successCount == 0
reg.Register(meetingGetStub("m_fail1", ""))
reg.Register(recordingErrStub("m_fail1", 121004, "not found"))
reg.Register(meetingGetStub("m_fail2", ""))
reg.Register(recordingErrStub("m_fail2", 121004, "not found"))
err := mountAndRun(t, VCNotes, []string{"+notes", "--meeting-ids", "m_fail1,m_fail2", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected batch failure error, got nil")
}
// typed partial-failure exit signal
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 ExitAPI (%d)", pfErr.Code, output.ExitAPI)
}
// stdout carries ok:false envelope with both failed notes
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if parseErr := json.Unmarshal(stdout.Bytes(), &env); parseErr != nil {
t.Fatalf("unmarshal stdout: %v\n%s", parseErr, stdout.String())
}
if env.OK {
t.Errorf("ok must be false on all-fail, got ok:true")
}
notes, _ := env.Data["notes"].([]interface{})
if len(notes) != 2 {
t.Fatalf("expected 2 notes in data, got %d\nstdout: %s", len(notes), stdout.String())
}
}

View File

@@ -18,6 +18,7 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
@@ -55,7 +56,7 @@ func extractMinuteToken(recordingURL string) string {
// fetchRecordingByMeetingID queries recording info for a single meeting.
func fetchRecordingByMeetingID(_ context.Context, runtime *common.RuntimeContext, meetingID string) map[string]any {
data, err := runtime.DoAPIJSON(http.MethodGet,
data, err := runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
@@ -97,14 +98,14 @@ var VCRecording = common.Shortcut{
{Name: "calendar-event-ids", Desc: "calendar event instance IDs, comma-separated for batch"},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOne(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
if err := common.ExactlyOneTyped(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
return err
}
const maxBatchSize = 50
for _, flag := range []string{"meeting-ids", "calendar-event-ids"} {
if v := runtime.Str(flag); v != "" {
if ids := common.SplitCSV(v); len(ids) > maxBatchSize {
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize).WithParam("--" + flag)
}
}
}
@@ -121,9 +122,11 @@ var VCRecording = common.Shortcut{
stored := auth.GetStoredToken(appID, userOpenID)
if stored != nil {
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scope(s): %s", strings.Join(missing, ", ")).
WithHint("run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")).
WithMissingScopes(missing...).
WithIdentity(string(runtime.As()))
}
}
}
@@ -212,9 +215,7 @@ var VCRecording = common.Shortcut{
fmt.Fprintf(errOut, "%s done: %d total, %d succeeded, %d failed\n", recordingLogPrefix, len(results), successCount, len(results)-successCount)
if successCount == 0 && len(results) > 0 {
outData := map[string]any{"recordings": results}
runtime.OutFormat(outData, &output.Meta{Count: len(results)}, nil)
return output.ErrAPI(0, fmt.Sprintf("all %d queries failed", len(results)), nil)
return runtime.OutPartialFailure(map[string]any{"recordings": results}, &output.Meta{Count: len(results)})
}
outData := map[string]any{"recordings": results}

View File

@@ -5,14 +5,21 @@ package vc
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
keyring "github.com/zalando/go-keyring"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -61,12 +68,26 @@ func TestRecording_Validation_ExactlyOne(t *testing.T) {
if err == nil {
t.Fatal("expected validation error for no flags")
}
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.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
// 两个 flag 都传了
err = mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m1", "--calendar-event-ids", "e1", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for two flags")
}
ve = nil
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("Subtype = %q, want SubtypeInvalidArgument", ve.Subtype)
}
}
func TestRecording_BatchLimit_MeetingIDs(t *testing.T) {
@@ -82,6 +103,16 @@ func TestRecording_BatchLimit_MeetingIDs(t *testing.T) {
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %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("expected subtype %q, got %q", errs.SubtypeInvalidArgument, ve.Subtype)
}
if !strings.HasPrefix(ve.Param, "--") {
t.Errorf("expected Param to start with '--', got %q", ve.Param)
}
}
func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
@@ -97,6 +128,65 @@ func TestRecording_BatchLimit_CalendarEventIDs(t *testing.T) {
if !strings.Contains(err.Error(), "too many IDs") {
t.Errorf("expected 'too many IDs' error, got: %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("expected subtype %q, got %q", errs.SubtypeInvalidArgument, ve.Subtype)
}
if !strings.HasPrefix(ve.Param, "--") {
t.Errorf("expected Param to start with '--', got %q", ve.Param)
}
}
func TestRecording_Validate_MissingScope(t *testing.T) {
keyring.MockInit() // use in-memory keyring to avoid macOS keychain popups
t.Setenv("HOME", t.TempDir())
cfg := defaultConfig()
// Store a token that intentionally lacks the vc:record:readonly scope.
token := &auth.StoredUAToken{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AccessToken: "test-user-access-token",
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "calendar:calendar:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
t.Cleanup(func() { _ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId) })
f, _, _, _ := cmdutil.TestFactory(t, cfg)
err := mountAndRun(t, VCRecording, []string{"+recording", "--meeting-ids", "m001", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected missing_scope error, got nil")
}
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if pe.Subtype != errs.SubtypeMissingScope {
t.Errorf("expected subtype %q, got %q", errs.SubtypeMissingScope, pe.Subtype)
}
if !strings.Contains(err.Error(), "missing required scope") {
t.Errorf("expected 'missing required scope' in message, got: %v", err)
}
found := false
for _, s := range pe.MissingScopes {
if s == "vc:record:readonly" {
found = true
break
}
}
if !found {
t.Errorf("expected MissingScopes to contain 'vc:record:readonly', got %v", pe.MissingScopes)
}
}
// ---------------------------------------------------------------------------
@@ -698,8 +788,10 @@ func TestRecording_Execute_AllFailed_ErrorMessage(t *testing.T) {
if !strings.Contains(e1, "data not found") {
t.Errorf("m001 error should contain API message, got: %s", e1)
}
if !strings.Contains(e2, "no permission") {
t.Errorf("m002 error should contain API message, got: %s", e2)
// code 121005 classifies to a typed permission error; the embedded
// result string surfaces the permission cause.
if !strings.Contains(e2, "permission") {
t.Errorf("m002 error should surface the permission failure, got: %s", e2)
}
if r1["meeting_id"] != "m001" {
t.Errorf("error result should preserve meeting_id, got: %v", r1["meeting_id"])
@@ -773,3 +865,59 @@ func TestRecording_Execute_RecordingGenerating(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}
// TestRecording_Execute_AllFailed_OutPartialFailure exercises the full
// VCRecording.Execute path via mountAndRun when every query fails.
// The batch should surface as an ok:false envelope on stdout (OutPartialFailure)
// carrying the per-item failures under data.recordings, and return a typed
// *output.PartialFailureError (ExitAPI) — no legacy ErrAPI / "all N queries failed".
func TestRecording_Execute_AllFailed_OutPartialFailure(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m001/recording",
Body: map[string]interface{}{"code": 121004, "msg": "data not found"},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m002/recording",
Body: map[string]interface{}{"code": 121005, "msg": "no permission"},
})
err := mountAndRun(t, VCRecording,
[]string{"+recording", "--meeting-ids", "m001,m002", "--format", "json", "--as", "user"},
f, stdout)
// 1. typed partial-failure exit signal — not a legacy ErrAPI string
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("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
// 2. stdout envelope: ok:false, data.recordings carries both failed items
raw := stdout.Bytes()
var env struct {
OK bool `json:"ok"`
Data struct {
Recordings []map[string]interface{} `json:"recordings"`
} `json:"data"`
}
if jsonErr := json.Unmarshal(raw, &env); jsonErr != nil {
t.Fatalf("unmarshal stdout: %v\nraw=%s", jsonErr, string(raw))
}
if env.OK {
t.Errorf("envelope ok must be false on all-failed batch, got ok:true\nstdout: %s", string(raw))
}
if len(env.Data.Recordings) != 2 {
t.Fatalf("expected 2 failed items in data.recordings, got %d\nstdout: %s", len(env.Data.Recordings), string(raw))
}
for _, rec := range env.Data.Recordings {
if rec["error"] == nil {
t.Errorf("each failed item must carry an error field; item: %v", rec)
}
}
}

View File

@@ -12,9 +12,9 @@ import (
"time"
"unicode/utf8"
"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 (
@@ -31,7 +31,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
}
@@ -47,14 +47,14 @@ 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
}
@@ -63,7 +63,7 @@ func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
st, _ := time.Parse(time.RFC3339, startTime)
et, _ := time.Parse(time.RFC3339, endTime)
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
@@ -138,16 +138,16 @@ func buildSearchBody(runtime *common.RuntimeContext, startTime, endTime string)
return body
}
func buildSearchParams(runtime *common.RuntimeContext) larkcore.QueryParams {
params := larkcore.QueryParams{}
func buildSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
if pageSize <= 0 {
pageSize = defaultVCSearchPageSize
}
params["page_size"] = []string{strconv.Itoa(pageSize)}
params["page_size"] = strconv.Itoa(pageSize)
if pageToken != "" {
params["page_token"] = []string{pageToken}
params["page_token"] = pageToken
}
return params
}
@@ -192,9 +192,9 @@ var VCSearch = common.Shortcut{
return err
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxVCSearchQueryLen {
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", defaultVCSearchPageSize, 1, maxVCSearchPageSize); err != nil {
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultVCSearchPageSize, 1, maxVCSearchPageSize); err != nil {
return err
}
for _, flag := range []string{"query", "start", "end", "organizer-ids", "participant-ids", "room-ids"} {
@@ -202,7 +202,7 @@ var VCSearch = common.Shortcut{
return nil
}
}
return common.FlagErrorf("specify at least one of --query, --start, --end, --organizer-ids, --participant-ids, or --room-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --query, --start, --end, --organizer-ids, --participant-ids, or --room-ids")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseTimeRange(runtime)
@@ -210,20 +210,10 @@ var VCSearch = common.Shortcut{
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchParams(runtime)
dryRunParams := map[string]interface{}{}
for key, values := range params {
if len(values) == 1 {
dryRunParams[key] = values[0]
} else if len(values) > 1 {
vs := make([]string, len(values))
copy(vs, values)
dryRunParams[key] = vs
}
}
dryRun := common.NewDryRunAPI().
POST("/open-apis/vc/v1/meetings/search")
if len(dryRunParams) > 0 {
dryRun.Params(dryRunParams)
if len(params) > 0 {
dryRun.Params(params)
}
return dryRun.Body(buildSearchBody(runtime, startTime, endTime))
},
@@ -232,7 +222,7 @@ var VCSearch = common.Shortcut{
if err != nil {
return err
}
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/meetings/search", buildSearchParams(runtime), buildSearchBody(runtime, startTime, endTime))
data, err := runtime.CallAPITyped("POST", "/open-apis/vc/v1/meetings/search", buildSearchParams(runtime), buildSearchBody(runtime, startTime, endTime))
if err != nil {
return err
}

View File

@@ -5,11 +5,13 @@ package vc
import (
"context"
"errors"
"strings"
"testing"
"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/shortcuts/common"
@@ -239,6 +241,16 @@ func TestSearch_Validation_InvalidPageSize(t *testing.T) {
if !strings.Contains(err.Error(), "must be between 1 and 30") {
t.Fatalf("unexpected error message: %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.Fatalf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--page-size" {
t.Fatalf("Param = %q, want --page-size", ve.Param)
}
}
func TestSearch_DryRun(t *testing.T) {
@@ -259,3 +271,182 @@ func TestSearch_InvalidTimeRange(t *testing.T) {
t.Fatal("expected error for invalid time")
}
}
// ---------------------------------------------------------------------------
// Typed error envelope assertions (errs migration lock-in)
// ---------------------------------------------------------------------------
func TestParseTimeRange_InvalidStart_TypedError(t *testing.T) {
cfg := &core.CliConfig{AppID: "test", AppSecret: "s", Brand: core.BrandFeishu}
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("start", "not-a-date")
runtime := common.TestNewRuntimeContext(cmd, cfg)
_, _, err := parseTimeRange(runtime)
if err == nil {
t.Fatal("expected error for invalid --start")
}
if !strings.Contains(err.Error(), "--start:") {
t.Errorf("message should contain '--start:', got: %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 \"--start\"", ve.Param)
}
}
func TestParseTimeRange_InvalidEnd_TypedError(t *testing.T) {
cfg := &core.CliConfig{AppID: "test", AppSecret: "s", Brand: core.BrandFeishu}
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("end", "not-a-date")
runtime := common.TestNewRuntimeContext(cmd, cfg)
_, _, err := parseTimeRange(runtime)
if err == nil {
t.Fatal("expected error for invalid --end")
}
if !strings.Contains(err.Error(), "--end:") {
t.Errorf("message should contain '--end:', got: %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 != "--end" {
t.Errorf("Param = %q, want \"--end\"", ve.Param)
}
}
func TestParseTimeRange_StartAfterEnd_TypedError(t *testing.T) {
cfg := &core.CliConfig{AppID: "test", AppSecret: "s", Brand: core.BrandFeishu}
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
_ = cmd.Flags().Set("start", "2026-03-25T00:00+08:00")
_ = cmd.Flags().Set("end", "2026-03-24T00:00+08:00")
runtime := common.TestNewRuntimeContext(cmd, cfg)
_, _, err := parseTimeRange(runtime)
if err == nil {
t.Fatal("expected error for start after end")
}
if !strings.Contains(err.Error(), "--start") || !strings.Contains(err.Error(), "--end") {
t.Errorf("message should mention --start and --end, got: %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 \"--start\"", ve.Param)
}
}
func TestSearch_Validation_QueryTooLong_TypedError(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("organizer-ids", "", "")
cmd.Flags().String("participant-ids", "", "")
cmd.Flags().String("room-ids", "", "")
cmd.Flags().String("page-size", "", "")
_ = cmd.Flags().Set("query", strings.Repeat("x", 51))
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := VCSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for overlong query")
}
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 != "--query" {
t.Errorf("Param = %q, want \"--query\"", ve.Param)
}
}
func TestSearch_Validation_NoFilter_TypedError(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, VCSearch, []string{"+search", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for no filter")
}
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)
}
}
func TestBuildSearchParams(t *testing.T) {
cfg := &core.CliConfig{AppID: "test", AppSecret: "s", Brand: core.BrandFeishu}
t.Run("defaults", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "", "")
runtime := common.TestNewRuntimeContext(cmd, cfg)
params := buildSearchParams(runtime)
if params["page_size"] != "15" {
t.Errorf("page_size = %v, want \"15\"", params["page_size"])
}
if _, ok := params["page_token"]; ok {
t.Error("page_token should be absent when not set")
}
})
t.Run("custom page-size and page-token", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "", "")
_ = cmd.Flags().Set("page-size", "20")
_ = cmd.Flags().Set("page-token", "tok123")
runtime := common.TestNewRuntimeContext(cmd, cfg)
params := buildSearchParams(runtime)
if params["page_size"] != "20" {
t.Errorf("page_size = %v, want \"20\"", params["page_size"])
}
if params["page_token"] != "tok123" {
t.Errorf("page_token = %v, want \"tok123\"", params["page_token"])
}
})
t.Run("values are scalars not slices", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "", "")
_ = cmd.Flags().Set("page-size", "10")
_ = cmd.Flags().Set("page-token", "p")
runtime := common.TestNewRuntimeContext(cmd, cfg)
params := buildSearchParams(runtime)
if _, isSlice := params["page_size"].([]string); isSlice {
t.Error("page_size must be a scalar string, not []string")
}
if _, isSlice := params["page_token"].([]string); isSlice {
t.Error("page_token must be a scalar string, not []string")
}
})
}

View File

@@ -6,6 +6,7 @@
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md),该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`
@@ -13,6 +14,7 @@
- **关键安全约束**:无论精确还是模糊,**无论命中 1 条还是多条,发起删除前都必须把候选(`name` + `space_id` + `description` + `space_type`)列给用户,由用户明确选定一个 `space_id` 再执行**。不要因为"只命中一条"就自动执行删除。
- 命中 0 条:停下来问用户是名称拼错了还是调用方无权限;**不要**自行改名字重试。
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门 / 应用”四类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
- 用户说“部门 + bot”这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。

View File

@@ -1,56 +1,35 @@
---
name: lark-approval
version: 1.0.0
description: "飞书审批 API审批实例审批任务管理。"
version: 1.1.0
description: "飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例审批待办不是飞书任务(任务类待办走 lark-task不负责创建审批定义和发起新审批。"
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli approval --help"
---
# approval (v4)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
## API Resources
所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval.<resource>.<method>` 查参数结构,不要猜字段。
## 选哪个命令
| 想做什么 | 命令 |
|---|---|
| 查待办/已办 | `tasks query``topic`1待办 2已办 17未读 18已读|
| 看表单/进度/当前节点 | `instances get` |
| 同意/拒绝 | `tasks approve` / `tasks reject` |
| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` |
| 催办 | `tasks remind` |
| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` |
处理链:`tasks query``instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作。
```bash
lark-cli schema approval.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli approval <resource> <method> [flags] # 调用 API
lark-cli approval tasks query --params '{"topic":"1"}' --as user
lark-cli approval tasks approve --data '{"instance_code":"<ic>","task_id":"<tid>","comment":"同意"}' --as user
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### instances
- `get` — 获取单个审批实例详情
- `cancel` — 撤回审批实例
- `cc` — 抄送审批实例
- `initiated` — 查询用户的已发起列表
### tasks
- `remind` — 催办审批人
- `approve` — 同意审批任务
- `reject` — 拒绝审批任务
- `transfer` — 转交审批任务
- `query` — 查询用户的任务列表
- `add_sign` — 审批任务加签
- `rollback` — 退回审批任务
## 权限表
| 方法 | 所需 scope |
|------|-----------|
| `instances.get` | `approval:instance:read` |
| `instances.cancel` | `approval:instance:write` |
| `instances.cc` | `approval:instance:write` |
| `instances.initiated` | `approval:instance:read` |
| `tasks.remind` | `approval:instance:write` |
| `tasks.approve` | `approval:task:write` |
| `tasks.reject` | `approval:task:write` |
| `tasks.transfer` | `approval:task:write` |
| `tasks.query` | `approval:task:read` |
| `tasks.add_sign` | `approval:task:write` |
| `tasks.rollback` | `approval:task:write` |
## 不在本 skill 范围
创建审批定义/发起新审批(走飞书客户端或审批管理后台);非审批类待办 → [`lark-task`](../lark-task/SKILL.md)

View File

@@ -1,7 +1,7 @@
---
name: lark-calendar
version: 1.0.0
description: "飞书日历calendar提供日历日程会议)的全面管理能力。核心场景包括:查看/搜索日程、创建/更新日程、管理参会人、查询忙闲状态及推荐空闲时段、查询/搜索与预定会议室。注意:涉及【预约日程/会议】或【查询/预定会议室】时,必须先读取 references/lark-calendar-schedule-meeting.md 工作流!高频操作请优先使用 Shortcuts+agenda快速概览今日/近期行程)、+create创建日程并按需邀请参会人及预定会议室、+update更新既有日程字段或独立增删参会人/会议室)、+freebusy查询用户主日历的忙闲信息和rsvp的状态、+rsvp回复日程邀请"
description: "飞书日历:管理日历日程会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc、待办任务走 lark-task"
metadata:
requires:
bins: ["lark-cli"]
@@ -10,93 +10,88 @@ metadata:
# calendar (v4)
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
**CRITICAL — 所有的 Shortcuts 在执行之前,务必先使用 Read 工具读取其对应的说明文档,禁止直接盲目调用命令。**
**CRITICAL — 凡涉及【预约日程/会议】或【查询/搜索会议室】,第一步 MUST 强制使用 Read 工具读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut**
**CRITICAL — 术语约束:用户日常表达中常说的“帮我约个日历”、“查一下今天的日历”等,其实际意图通常是针对 日程Event 的创建或查询,而非操作 日历Calendar 容器本身。请自动将口语化的“日历”意图映射为“日程”操作(如 `+create`, `+agenda`)。**
**CRITICAL — 会议与日程的意图路由:**
- **查询过去时间的会议**:如果用户明确查询过去时间的会议(如“昨天的会议”、“上周的会议”),**优先使用 [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md) 搜索会议记录**。因为会议数据不仅包含从日程发起的视频会议,还包含即时会议,仅查询日程数据会导致结果不全。
- **查询日历/日程或未来时间的会议**:如果用户明确表达的是“日历”、“日程”,或者涉及**未来时间**的安排则属于本技能lark-calendar的业务域请继续使用本技能处理。
**CRITICAL — 任务类型分流:处理“预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间”时,必须先判断用户是在“新建日程”还是“编辑已有日程”。**
- **编辑已有日程的强信号**:用户明确提到某个已存在的日程锚点(如标题、时间段、`这个日程``这场会`)并表达修改动作(如“添加”“移除”“改到”“换会议室”“调整时间”)。这类请求默认走**编辑已有日程**,绝不能直接按新建处理。
- **编辑已有日程的前置步骤**一旦判定为编辑MUST 先定位目标日程或具体实例的 `event_id`再继续后续流程。若是重复性日程MUST 先定位到对应实例的 `event_id`
- **新建日程**:只有当用户表达的是“新约一个会/创建一个日程/安排一次会议”等新增意图,且没有指向某个既有日程的修改动作时,才进入新建流程。
开始前先读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)认证、权限处理)。
**CRITICAL — 验证与同步延迟在涉及删除日程delete、修改日程patch或者涉及添加移除参与人/会议室之后如果需要进行二次查询验证操作结果MUST 等待至少 2 秒后再进行查询,以防止因数据同步延迟导致查不到最新数据。注意:不要向用户提及你等待了这 2 秒钟的事情。**
**CRITICAL — 凡涉及预约日程/会议或查询/搜索会议室,第一步 MUST 读 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)。禁止跳过此步直接调用 API 或 Shortcut**
**CRITICAL — 重复性日程的实例操作:目前已经完全具备对重复性日程的某个具体实例进行操作的能力(例如:编辑某个实例、删除某个实例、为某个实例添加/删除参与人、为某个实例添加/移除会议室)。只要在对应的操作中传递对应实例的 `event_id` 即可。因此MUST 先定位到对应的那次实例的 `event_id`(可通过 `events search_event` 搜索日程,或 `+agenda` 查看对应时间范围的日程等相关查询获取),绝对禁止直接使用原重复性日程的 `event_id` 进行操作。**
## 身份
**时间与日期推断规范:**
为确保准确性,在涉及时间推断时,请严格遵循以下规则:
- **星期的定义**:周一是一周的第一天,周日是一周的最后一天。计算`下周一`等相对日期时,务必基于当前真实日期和星期基准进行推算,避免算错日期。
- **一天的范围**:当用户提到`明天``今天`等泛指某一天时,时间范围应默认覆盖整天时间范围。**切勿**自行缩减查询范围,以免遗漏晚上的时间安排。
- **历史时间约束**:不能预约已经完全过去的时间。唯一的例外情况是“跨越当前时间”的日程,即日程的开始时间在过去,但结束时间在未来。
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
## 核心场景
```bash
# BAD — bot 身份查用户日程,返回空列表
lark-cli calendar +agenda --as bot
### 1. 预约新日程/会议、编辑已有日程、查询/搜索可用会议室
**BLOCKING REQUIREMENT (阻塞性要求): 只要用户的意图包含“预约日程/会议”或“查询/搜索可用会议室”,你必须立即停止其他思考,优先使用 Read 工具完整读取 [`references/lark-calendar-schedule-meeting.md`](references/lark-calendar-schedule-meeting.md)!未读取该文件前,绝对禁止执行任何日程创建或会议室查询操作。**
**CRITICAL: 必须严格按照上述文档中定义的工作流Workflow执行后续操作。处理该场景时默认做“智能助理”不要做“表单填写机”。能补全的默认值先补全只有在时间冲突、结果无法唯一确定、时间语义存在歧义时才主动追问。**
**CRITICAL: 执行顺序必须固定为:先判断任务类型(新建/编辑);若为编辑先定位目标日程 `event_id`;再补默认值或继承已定位日程的已知信息;再判断时间是否明确;最后进入“明确时间”或“模糊时间/无时间信息”分支。不要跳步。**
**CRITICAL: 明确时间且需要会议室时,先基于最终确定的时间块执行 `+room-find`,再按需执行 `+freebusy`;模糊时间或无时间信息时,先 `+suggestion`,如需会议室再批量 `+room-find`。如果是编辑已有日程且不改时间,只新增会议室,则必须基于已定位日程的原始时间执行 `+room-find`,且最终落地时默认保留已存在的会议室;只有用户明确表达“更换会议室”或“移除会议室”时,才删除原会议室。**
**CRITICAL: 当用户说“查会议室”“找会议室”“搜可用会议室”或“推荐常用会议室”时,默认是查会议室可用性,不是查会议室资源名录,更严禁拉取历史日程做统计分析。完整规则以 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) 为准。**
**BLOCKING REQUIREMENT: 即使用户的核心诉求是“查会议室”,只要【没有提供明确的起止时间】,绝对禁止直接调用 `+room-find`!必须先进入【无时间/模糊时间】分支,调用 `+suggestion` 拿到候选时间块后,再将时间块传给 `+room-find`。**
**BLOCKING REQUIREMENT: 只要面临时间方案或会议室方案的选择(如模糊时间、无时间或需要会议室),在最终执行创建新日程或更新既有日程之前,必须先向用户展示候选方案并等待用户明确确认。绝对禁止擅自替用户做决定。**
## 核心概念
- **日历Calendar**日程的容器。每个用户有一个主日历primary calendar也可以创建或订阅共享日历。
- **日程Event**日历中的单个日程包含起止时间、地点、标题、参与人等属性。支持单次日程和重复日程遵循RFC5545 iCalendar国际标准。
- ***全天日程All-day Event***: 只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **日程实例Instance**日程的具体时间实例本质是对日程的展开。普通日程和例外日程对应1个Instance重复性日程对应N个Instance。在按时间段查询时可通过实例视图将重复日程展开为独立的实例返回以便在时间线上准确展示和管理。
- **重复规则Rrule/Recurrence Rule**:定义重复性日程的重复规则,比如`FREQ=DAILY;UNTIL=20230307T155959Z;INTERVAL=14`表示每14天重复一次。
- **例外日程Exception**:重复性日程中与原重复性日程不一致的日程。
- **参会人Attendee**日程的参与者可以是用户、群、会议室资源、外部邮箱地址等。每个参与人有独立的RSVP状态。
- **响应状态RSVP**:参与人对日程邀请的回复状态(接受/拒绝/待定)。
- **忙闲时间FreeBusy**:查询用户在指定时间段的忙闲状态,用于会议时间协调。
- **会议室Room**“room”不是“房间”是“会议室”。请在理解和处理意图时将“room”和“房间”准确映射为“会议室”及其相关操作。
- **时间块Time Slot / Time Block**:指一个**具体且确定**的连续时间段(如 `14:00~15:00`)。在文档中,它与泛指的“时间范围/区间”(如“今天下午”、“下周”)有严格区别。在调用预定、查询可用会议室等确切操作时,必须基于确定的“时间块”而非模糊的“时间范围”。
## 资源关系
```
Calendar (日历)
└── Event (日程)
├── Attendee (参会人)
└── Reminder (提醒)
# GOOD — user 身份查日程
lark-cli calendar +agenda --as user
```
## Shortcuts(推荐优先使用)
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
## Shortcuts
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |
| [`+create`](references/lark-calendar-create.md) | 创建日程并邀请参会人ISO 8601 时间) |
| [`+update`](references/lark-calendar-update.md) | 更新既有日程字段,或独立增量添加/移除参会人和会议室 |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和rsvp的状态 |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(**无明确时间时禁止直接调用,需先走 +suggestion** |
| [`+freebusy`](references/lark-calendar-freebusy.md) | 查询用户主日历的忙闲信息和 RSVP 状态 |
| [`+room-find`](references/lark-calendar-room-find.md) | 针对一个或多个**明确的**时间块查找可用会议室(无明确时间时禁止直接调用,需先走 +suggestion |
| [`+rsvp`](references/lark-calendar-rsvp.md) | 回复日程(接受/拒绝/待定) |
| [`+suggestion`](references/lark-calendar-suggestion.md) | 根据非明确时间或一段时间范围,推荐多个可用时间块方案 |
## 会议室相关规则
## 前置条件路由
- **会议室是日程的一种参与人resource attendee不能脱离日程单独存在或单独预定。**
- **凡是用户意图是“预定/查询/搜索可用会议室”时,都必须进入 `references/lark-calendar-schedule-meeting.md` 工作流处理。**
- `+room-find` 的时间输入必须是**确定时间块**,不能是时间区间搜索。
- **强制约束:如果用户仅要求“查询会议室”但未提供明确时间,必须先调用 `+suggestion` 获取可用时间块,然后再将时间块交给 `+room-find` 批量查询。严禁直接猜测时间并盲目调用 `+room-find`。**
- **编辑已有日程时,如果用户表达的是“添加会议室/再加一个会议室”,默认语义是增量添加,必须保留已有会议室;只有在用户明确表达“更换会议室”“把原会议室换掉”“移除会议室”时,才执行旧会议室删除。**
| 场景 | 前置要求 |
|------|----------|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID |
| 删除/修改后验证 | 等待 2 秒再查询API 最终一致性),不要告知用户你等待了 |
| 调用任何 Shortcut | 先读其对应 reference 文档 |
## 核心概念
- **日程实例Instance**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`
- **全天日程All-day Event**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
- **会议室Room**"room"不是"房间",是"会议室"。会议室是日程的一种参与人resource attendee不能脱离日程单独预定。
## 术语映射
用户日常说的"帮我约个日历""查一下今天的日历",实际意图是针对**日程Event**的创建或查询而非操作日历Calendar容器本身。自动将口语化的"日历"意图映射为"日程"操作。
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| 查询过去的会议("昨天的会议""上周的会" | [`../lark-vc/SKILL.md`](../lark-vc/SKILL.md)(会议数据含即时会议,仅查日程会遗漏) |
| 查询日历/日程或未来时间的会议 | 本 skill |
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
## 任务类型分流
处理"预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间"时,必须先判断新建 vs 编辑:
- **编辑已有日程的强信号**:用户提到已存在的日程锚点(标题、时间段、`这个日程``这场会`)并表达修改动作(添加、移除、改到、换会议室、调整时间)。默认走编辑流,绝不能按新建处理。
- **新建日程**:用户表达新增意图("新约一个会""创建一个日程""安排一次会议"),且没有指向既有日程的修改动作。
## 时间推断规范
- **星期的定义**:周一是一周的第一天,周日是最后一天。计算"下周一"等相对日期时,基于当前真实日期推算。
- **一天的范围**:用户提到"明天""今天"等泛指某天时,时间范围应覆盖整天,不要自行缩减。
- **历史时间约束**:不能预约已经完全过去的时间。唯一例外是"跨越当前时间"的日程(开始在过去、结束在未来)。
## 会议室规则
- 凡是"预定/查询/搜索可用会议室",都必须进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md)。
- `+room-find` 的时间输入必须是确定时间块,不能是时间区间搜索。
- 用户仅要求"查会议室"但未提供明确时间时,必须先调用 `+suggestion` 获取可用时间块,再将时间块交给 `+room-find`。严禁猜测时间盲目调用。
- 编辑已有日程时,"添加会议室"默认是增量语义,保留已有会议室;只有用户明确说"更换会议室""移除会议室"时才删除旧会议室。
## API Resources
```bash
lark-cli schema calendar.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli calendar <resource> <method> [flags] # 调用 API
lark-cli calendar <resource> <method> [flags]
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### calendars
- `create` — 创建共享日历
@@ -120,35 +115,18 @@ lark-cli calendar <resource> <method> [flags] # 调用 API
- `get` — 获取日程
- `instance_view` — 查询日程视图
- `patch` — 更新日程
- `search_event` — 搜索日程(目前只会返回日程id、日程主题、日程时间的信息需要更多的日程详情需要`events get` 命令
- `search_event` — 搜索日程(仅返回 日程ID/主题/时间,详情需`events get`
- `share_info` — 获取日程分享链接
### freebusys
- `list` — 查询主日历日程忙闲信息
## 权限表
## 不在本 skill 范围
| 方法 | 所需 scope |
|------|-----------|
| `calendars.create` | `calendar:calendar:create` |
| `calendars.delete` | `calendar:calendar:delete` |
| `calendars.get` | `calendar:calendar:read` |
| `calendars.list` | `calendar:calendar:read` |
| `calendars.patch` | `calendar:calendar:update` |
| `calendars.primary` | `calendar:calendar:read` |
| `calendars.search` | `calendar:calendar:read` |
| `event.attendees.batch_delete` | `calendar:calendar.event:update` |
| `event.attendees.create` | `calendar:calendar.event:update` |
| `event.attendees.list` | `calendar:calendar.event:read` |
| `events.create` | `calendar:calendar.event:create` |
| `events.delete` | `calendar:calendar.event:delete` |
| `events.get` | `calendar:calendar.event:read` |
| `events.instance_view` | `calendar:calendar.event:read` |
| `events.patch` | `calendar:calendar.event:update` |
| `events.search_event` | `calendar:calendar.event:read` |
| `events.share_info` | `calendar:calendar.event:read` |
| `freebusys.list` | `calendar:calendar.free_busy:read` |
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 待办任务管理 → [lark-task](../lark-task/SKILL.md)
- 会议室物理设施管理 → 管理员后台
**注意(强制性):**
- 涉及日期(时间)字符串与时间戳的相互转换时,务必调用系统命令或脚本代码等外部工具进行处理,以确保转换的绝对准确。违者将导致严重的逻辑错误!

View File

@@ -1,6 +1,7 @@
---
name: lark-doc
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。使用本 skill 时docs +create、docs +fetch、docs +update 必须携带 --api-version v2默认使用 DocxXML也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
version: 2.0.0
description: "飞书云文档Docx / Wiki 文档v2 API读取和编辑飞书文档内容。当用户给出文档 URL 或 token或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill路由依据是 URL 路径模式和 token而不是域名。不负责文档评论管理也不负责表格或 Base 的数据操作。"
metadata:
requires:
bins: ["lark-cli"]
@@ -9,7 +10,9 @@ metadata:
# docs (v2)
> **⚠️ API 版本:本 skill 使用 v2 API。所有 `docs +create --api-version v2`、`docs +fetch --api-version v2`、`docs +update --api-version v2` 命令必须携带 `--api-version v2`。**
**身份:文档操作默认使用 `--as user`。首次使用前执行 `lark-cli auth login`。**
> **CRITICAL — API 版本:本 skill 使用 v2 API。执行 `docs +create`、`docs +fetch`、`docs +update` 时必须显式传入 `--api-version v2`。**
```bash
# 常用示例
@@ -68,3 +71,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
| [`+whiteboard-update`](../lark-whiteboard/references/lark-whiteboard-update.md) | Alias of `whiteboard +update`. Update an existing whiteboard with DSL, Mermaid or PlantUML. Prefer `whiteboard +update`; refer to lark-whiteboard skill for details. |
## 不在本 Skill 范围
- 文档评论管理 → [`lark-drive`](../lark-drive/SKILL.md)
- 电子表格或 Base 的数据操作 → [`lark-sheets`](../lark-sheets/SKILL.md) / [`lark-base`](../lark-base/SKILL.md)
- 云空间文件上传、下载、权限管理 → [`lark-drive`](../lark-drive/SKILL.md)

View File

@@ -1,10 +1,9 @@
# docs +create创建飞书云文档
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 4. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
@@ -85,4 +84,3 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -1,8 +1,6 @@
# docs +fetch获取飞书云文档
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
## 命令
```bash
@@ -136,5 +134,4 @@ lark-cli docs +fetch --api-version v2 --doc Z1Fj...tnAc \
- [lark-doc-create](lark-doc-create.md) — 创建文档
- [lark-doc-update](lark-doc-update.md) — 更新文档
- [lark-doc-media-preview](lark-doc-media-preview.md) — 预览素材
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
- [lark-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图

View File

@@ -69,3 +69,7 @@ Markdown 格式支持通过 URL 插入网络图片,图片将自动从 HTTP 下
非原生 Markdown 语法的内容(如下划线、高亮框(Callout)、勾选框、多维表格、画板、思维导图、电子表格、网格布局、引用(@文档/@人)、按钮、日期提醒、行内文件、文字颜色/背景色、同步块等)采用 XML 语法表示,详见 [`lark-doc-xml.md`](lark-doc-xml.md)。
> **⚠️ XML 标签会被解析并生效**:即使在 `--doc-format markdown` 下,`<b>`、`<u>`、`<img>` 等 XML 标签也会被识别为对应的富文本节点,**不会**按字面量显示。如需字面量输出尖括号包裹的文本(例如示例中的 `<tag>`),必须转义左尖括号:`\<b>`、`\<img>`。
## 参考
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范

View File

@@ -2,10 +2,9 @@
# docs +update更新飞书云文档
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 1. [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证、全局参数和安全规则
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 3. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 4. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md)
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
@@ -249,4 +248,3 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
- [`lark-doc-media-insert.md`](lark-doc-media-insert.md) — 插入图片/文件到文档
- [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -32,6 +32,8 @@ metadata:
- 用户要获取某个文件的封面图,优先使用 `lark-cli drive +cover`;先 `--list-only` 看规格,再选 `--spec` 下载。
- 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token <wiki_token>`;不要误切到 `wiki` 域命令。
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`
- 用户给的是 wiki URL / token且后续还没明确底层资源类型时先用 `lark-cli drive +inspect` 解包;`+inspect` 失败后不要自动切到别的写接口继续尝试先按错误提示处理权限、scope 或链接问题。
- `drive +inspect` / `drive +upload` 遇到 `not found``permission denied``missing scope` 时,默认停止重试;只有 `rate limit` 或临时网络错误才适合有限重试。
## 修改标题
- 使用 `drive files patch` 命令通过new_title字段可以修改标题支持 docx、sheet、bitable、file、wiki、folder 类型

View File

@@ -69,7 +69,7 @@ wait
### stdin EOF = graceful exit
`event consume` treats stdin close as a shutdown signal (wired for AI subprocess callers). `< /dev/null` / `nohup` / systemd's default `StandardInput=null` will cause an immediate graceful exit (stderr `reason: signal`). To keep running:
`event consume` treats stdin close as a shutdown signal (wired for AI subprocess callers). **Bounded runs are exempt: when `--max-events` or `--timeout` is set (> 0), stdin EOF is ignored and the run exits only via its own bound, timeout, or SIGTERM.** For unbounded runs, `< /dev/null` / `nohup` / systemd's default `StandardInput=null` will cause an immediate graceful exit (stderr `reason: signal`). To keep an unbounded run alive:
- Feed stdin a source that never EOFs: `< <(tail -f /dev/null)`
- Or run bounded: `--max-events N` / `--timeout D`
@@ -82,7 +82,7 @@ On exit, the last stderr line is `[event] exited — received N event(s) in Xs (
|---|---|---|
| 0 | `reason: limit` | `--max-events` reached |
| 0 | `reason: timeout` | `--timeout` reached |
| 0 | `reason: signal` | Ctrl+C / SIGTERM / stdin EOF |
| 0 | `reason: signal` | Ctrl+C / SIGTERM / stdin EOF (stdin EOF applies to unbounded runs only) |
| non-0 | `Error: ...` (no `exited` line) | Startup / runtime failure (permissions, network, params, config) |
Orchestrators should treat `reason: limit/timeout/signal` (all exit 0) as "business completion" and non-zero as "failure".

View File

@@ -59,7 +59,7 @@ JSON keeps the raw envelope and adds `chat_name` to each resolvable item:
}
```
A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0.
A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. p2p (direct) chats also omit `chat_name`: the server returns an empty `name` for them (the client UI shows the partner's display name instead); if a label is needed, fetch the chat via `chats/batch_query`, read `p2p_target_id`, and resolve it with a contact lookup.
## See also

View File

@@ -35,7 +35,7 @@ lark-cli im +feed-group-query-item --as user \
The command sends `{"items":[{"feed_id":"oc_a","feed_type":"chat"},{"feed_id":"oc_b","feed_type":"chat"}]}`, then enriches the response (`items[]` and `deleted_items[]`) with `chat_name` exactly as `+feed-group-list-item` does. There is no pagination for this method.
A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0.
A feed card whose chat cannot be resolved (soft-deleted or no permission) simply omits `chat_name` — the command still exits 0. p2p (direct) chats also omit `chat_name`: the server returns an empty `name` for them (the client UI shows the partner's display name instead); if a label is needed, fetch the chat via `chats/batch_query`, read `p2p_target_id`, and resolve it with a contact lookup.
## See also

View File

@@ -38,7 +38,7 @@ The two `*-item` shortcuts resolve `chat_name` via a follow-up `chats/batch_quer
## Common Notes
- `feed_group_id` is the feed-group identifier returned by `create`, typically formatted as `ofg_xxx`. In meta examples it appears as a string; on the wire it is the group's stable ID.
- `feed_group_id` is the feed-group identifier returned by `create`, typically formatted as `ofg_xxx`. It is an opaque string — the group's stable ID.
- `feed_id` is the identifier of one feed card inside a group. In v1 only the `chat` feed card type is supported (see `feed_card_type` below), so `feed_id` is currently a chat ID such as `oc_xxx`.
- All `feed.groups.*` methods require `user_access_token`. Run with `--as user`; bot/tenant tokens are rejected.
- Read APIs (`batch_query`, `list`, `batch_query_item`, `list_item`) return **two parallel lists**: a live list (`groups[]` or `items[]`) and a soft-deleted list (`deleted_groups[]` or `deleted_items[]`). Consumers tracking incremental sync should consume both.
@@ -268,7 +268,7 @@ lark-cli im feed.groups batch_add_item --as user \
| `--data` | `items[].feed_id` | No | The chat ID to add (e.g. `oc_xxx`) |
| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. |
> Note: `items[].feed_id` is marked `Required: No` in the meta but every element of `items` must set it — a missing field yields an unusable entry. Always pass `{"feed_id": "oc_xxx", "feed_type": "chat"}` per item.
> Note: `items[].feed_id` is not marked as required in the API schema, but every element of `items` must set it — a missing field yields an unusable entry. Always pass `{"feed_id": "oc_xxx", "feed_type": "chat"}` per item.
### Response
@@ -314,7 +314,7 @@ lark-cli im feed.groups batch_remove_item --as user \
| `--data` | `items[].feed_id` | No | The chat ID to remove |
| `--data` | `items[].feed_type` | Yes (`"chat"` only) | Wire-typed as an open string. v1 OAPI service accepts only `chat`; anything else is rejected at runtime. See the Enums section. |
> Note: same caveat as `batch_add_item` — `items[].feed_id` is `Required: No` per the meta but must be present in practice.
> Note: same caveat as `batch_add_item` — `items[].feed_id` is optional per the API schema but must be present in practice.
### Response
@@ -330,7 +330,7 @@ Shortcut-only: [`+feed-group-list-item`](lark-im-feed-group-list-item.md). Lists
## Enums
The enums below are sourced from the internal datasync IDL (`lark.im.datasync.open.thrift`). All values listed here are exhaustive.
All enum values listed here are exhaustive.
### `feed_group_type`
@@ -341,7 +341,7 @@ Used in `feed_group_creator.type` and the response `groups[].type`.
### `feed_card_type`
Used in `items[].feed_type` everywhere a feed card appears. Wire type is the open string alias `FeedCardTypeV1`.
Used in `items[].feed_type` everywhere a feed card appears. Wire type is an open string.
- `chat` — the only value the v1 OAPI service accepts. `feed_id` is therefore a chat ID such as `oc_xxx`.
@@ -399,7 +399,7 @@ Used inside `feed_group_updater.update_fields`. Multiple values may be listed.
- `1` — update name only.
- `2` — update rules only.
Wire form: integers from the `FeedGroupUpdateField` enum (`1` = name, `2` = rules). The server rejects the lowercase string forms (`"name"`, `"rules"`) with `9499 Invalid parameter value`. Omit the array (or pass an empty array) to make no field updates.
Wire form: integers (`1` = name, `2` = rules). The server rejects the lowercase string forms (`"name"`, `"rules"`) with `9499 Invalid parameter value`. Omit the array (or pass an empty array) to make no field updates.
## feed_group_rules
@@ -450,5 +450,3 @@ If a required scope is missing, the CLI surfaces a hint such as `lark-cli auth l
- [lark-im](../SKILL.md) — all IM commands
- [lark-shared](../../lark-shared/SKILL.md) — authentication and global parameters
- Design wiki: `https://bytedance.larkoffice.com/wiki/LIdSwrCzaitg3MkH8oScLhBCnFQ`
- IDL source (internal): `lark.im.datasync.open.thrift`

View File

@@ -10,7 +10,7 @@ A message can have flags on both layers simultaneously:
- Message layer: `(default, message)`
- Feed layer: `(thread, feed)` or `(msg_thread, feed)` depending on chat type
**When no `--flag-type` is specified, the shortcut performs double-cancel**: removes both message layer and feed layer flags. The server handles cancel requests for non-existent flags idempotently, so this is safe.
**When no `--flag-type` is specified, the shortcut performs best-effort double-cancel**: the message-layer flag is always removed; the feed-layer flag is also removed when the chat type can be determined (otherwise a warning is printed on stderr and the feed layer is skipped). The server handles cancel requests for non-existent flags idempotently, so this is safe.
**Feed layer item_type is determined by chat_mode**:
- Topic-style chat (`chat_mode=topic`) → `item_type=thread`
@@ -37,7 +37,7 @@ lark-cli im +flag-cancel --as user --message-id om_xxx --dry-run
| Parameter | Required | Description |
|------|------|------|
| `--message-id <om_xxx>` | Required | Message ID |
| `--flag-type <name>` | No | `message` or `feed`; **when omitted, double-cancels both layers** |
| `--flag-type <name>` | No | `message` or `feed`; **when omitted, best-effort double-cancel of both layers** |
| `--item-type <name>` | No | `default\|thread\|msg_thread`; required when `--flag-type feed` |
| `--as user` | Required | Currently only supports user identity |

View File

@@ -15,6 +15,7 @@ metadata:
## 快速决策
- 身份Markdown 文件通常属于用户云空间资源,优先使用 `--as user`。如为自动化场景,或应用已创建并持有目标文件权限,可按场景使用 `--as bot`。首次以 `user` 身份访问前执行 `lark-cli auth login`
- `markdown +create` / `+overwrite` 失败时,先判断是不是身份和权限问题:`bot` 更常见的是 app scope 或目标目录 ACL`user` 更常见的是用户授权或用户 ACL不要不加判断地来回切身份重试。
- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create`
- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff`
@@ -24,6 +25,7 @@ metadata:
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
- 用户要把本地 Markdown **导入成在线新版文档docx**,不要用本 skill改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill切到 [`lark-drive`](../lark-drive/SKILL.md)
- `markdown +create` / `+overwrite` 命中 `missing scope``permission denied``not found``version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
## 核心边界

View File

@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围2.获取妙记基础信息(标题、封面、时长 等3.下载妙记音视频文件4.获取妙记相关 AI 产物总结、待办、章节5.上传音视频生成妙记,也支持将本地音视频文件转成纪要逐字稿、文字稿、撰写文字等产物6.更新妙记标题重命名妙记7.替换妙记逐字稿中的说话人。遇到这类请求时,应优先使用本 skill。飞书妙记 URL 格式: http(s)://<host>/minutes/<minute-token>"
description: "飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要逐字稿(优先使用本 skill不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc"
metadata:
requires:
bins: ["lark-cli"]
@@ -18,10 +18,40 @@ metadata:
> 3. 了解不同会议产物的组成部分,以便根据需求决策使用哪种产物的数据
> 4. 了解会议总结、分析和信息提取的标准流程
## 身份
所有 minutes 命令默认使用 `--as user`
## Shortcuts
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-minutes-search.md) | 按关键词、所有者、参与者、时间范围搜索妙记 |
| [`+download`](references/lark-minutes-download.md) | 下载妙记音视频媒体文件 |
| [`+upload`](references/lark-minutes-upload.md) | 上传 file_token 生成妙记 |
| [`+update`](references/lark-minutes-update.md) | 更新妙记标题 |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | 替换妙记逐字稿中的说话人(仅支持用户 ID不支持姓名 |
- 使用任何 Shortcut 前,必须先读其对应 reference 文档。
## 意图路由
| 用户意图 | 路由到 |
|----------|--------|
| "我的妙记""搜索妙记""妙记列表" | 本 skill`+search` |
| "这个妙记的标题/时长/封面/链接" | 本 skill`minutes get` |
| "下载妙记的视频/音频" | 本 skill`+download` |
| "把音视频转妙记/上传文件生成妙记" | 本 skill`+upload` |
| "重命名妙记/改妙记标题" | 本 skill`+update` |
| "替换说话人/把 A 的发言改成 B" | 本 skill`+speaker-replace` |
| "这个妙记的逐字稿/总结/待办/章节" | [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens` |
| "把音视频文件转成纪要/逐字稿/文字稿" | 先本 skill`+upload`),再 [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens` |
| 用户同时提到"会议/开会"和"妙记" | 先 [lark-vc](../lark-vc/SKILL.md)`+search``+recording`),再本 skill |
## 核心概念
- **妙记Minutes**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
- **妙记 Tokenminute\_token**:妙记的唯一标识符,可从妙记 URL 末尾提取(`https://*.feishu.cn/minutes/obcnxxxxxxxxxxxxxxxxxxxx` 中的 `obcnxxxxxxxxxxxxxxxxxxxx`)。如果 URL 中包含额外参数(如 `?xxx`截取路径最后一段。
- **妙记 Tokenminute_token**:妙记的唯一标识符,可从妙记 URL 末尾提取(如 `https://*.feishu.cn/minutes/obcnxxx` 中的 `obcnxxx`)。如果 URL 中包含额外参数(如 `?xxx`),截取路径最后一段。
## 核心场景
@@ -30,7 +60,7 @@ metadata:
1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`
2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
4. 如果是会议的妙记,应优先通过 [lark-vc](../lark-vc/SKILL.md) 定位会议并获取 `minute_token`
5. 会议场景的妙记路由,以及"参与的妙记"如何解释,统一以 [minutes +search](references/lark-minutes-search.md) 为准。
@@ -46,7 +76,7 @@ metadata:
### 3. 下载妙记音视频文件
1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
2. `minutes +download` 只负责音视频媒体文件。
2. `+download` 只负责音视频媒体文件。用户需要逐字稿、总结、待办、章节等纪要内容时,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
4. 未显式指定路径时,文件默认落到 `./minutes/{minute_token}/<server-filename>`,与 `vc +notes` 的逐字稿共享同一目录便于聚合。
@@ -107,49 +137,20 @@ Minutes (妙记) ← minute_token 标识
> - 用户说"重命名妙记 / 改妙记标题 / 修改妙记名字" → `minutes +update`
> - 用户说"替换说话人 / 把 A 的发言改成 B / 重新归属发言人" → `minutes +speaker-replace`
## Shortcuts推荐优先使用
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
| [`+upload`](references/lark-minutes-upload.md) | Upload a media file token to generate a minute |
| [`+update`](references/lark-minutes-update.md) | Update a minute's title |
| [`+speaker-replace`](references/lark-minutes-speaker-replace.md) | Replace a speaker in a minute's transcript (rebind from one user to another) |
- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
- 使用 `+upload` 命令时,必须阅读 [references/lark-minutes-upload.md](references/lark-minutes-upload.md),了解生成参数和返回值结构。
- 使用 `+update` 命令时,必须阅读 [references/lark-minutes-update.md](references/lark-minutes-update.md),了解修改参数和返回值结构。
- 使用 `+speaker-replace` 命令时,必须阅读 [references/lark-minutes-speaker-replace.md](references/lark-minutes-speaker-replace.md),了解参数和限制(仅支持用户 ID不支持姓名
<!-- AUTO-GENERATED-START — gen-skills.py 管理,勿手动编辑 -->
## API Resources
```bash
lark-cli schema minutes.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli minutes <resource> <method> [flags] # 调用 API
lark-cli minutes <resource> <method> [flags]
```
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
### minutes
- `get` — 获取妙记信息
> **权限错误**:如果返回 `[2091005] permission deny`,表示用户没有对应妙记文件的阅读权限,需提示用户联系妙记 owner 申请权限。
## 权限表
## 不在本 skill 范围
| 方法 | 所需 scope |
| ------------- | ------------------------------ |
| `+search` | `minutes:minutes.search:read` |
| `minutes.get` | `minutes:minutes:readonly` |
| `+download` | `minutes:minutes.media:export` |
| `+update` | `minutes:minutes:update` |
| `+speaker-replace` | `minutes:minutes:update` |
<!-- AUTO-GENERATED-END -->
- 纪要/逐字稿/总结/待办/章节内容获取 → [lark-vc](../lark-vc/SKILL.md)`vc +notes --minute-tokens`
- 搜索历史会议记录 → [lark-vc](../lark-vc/SKILL.md)
- 查询未来的会议日程 → [lark-calendar](../lark-calendar/SKILL.md)

View File

@@ -126,6 +126,7 @@ lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_
- **禁止输出密钥**appSecret、accessToken到终端明文。
- **写入/删除操作前必须确认用户意图**。
- 用 `--dry-run` 预览危险请求。
- **文件路径只接受相对路径**`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON优先用 stdin 传入,避免路径和转义问题。
## 高风险操作的审批协议exit 10

View File

@@ -1,7 +1,7 @@
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责:云文档内容编辑(走 lark-doc、云文档里的独立画板对象走 lark-whiteboard注意 slide 内嵌的流程图/架构图仍属本 skill、上传或下载普通文件走 lark-drive"
metadata:
requires:
bins: ["lark-cli"]
@@ -21,10 +21,11 @@ metadata:
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)其中包含认证、权限处理**
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
@@ -83,6 +84,7 @@ lark-cli auth login --domain slides
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
@@ -265,12 +267,14 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
没有 Shortcut 覆盖时使用原生 API。高频资源`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
```bash
lark-cli schema slides.<resource>.<method> # 调用 API 前必须先查看参数结构
lark-cli slides <resource> <method> [flags] # 调用 API
```
原生 API 高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段。
> **重要**使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜字段格式
## 核心规则
@@ -283,17 +287,4 @@ lark-cli slides <resource> <method> [flags] # 调用 API
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `slides +replace-slide` | `slides:presentation:update`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
| `xml_presentation.slide.create` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.delete` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
# IconPark 图标
IconPark 图标通过 `<icon>` 写入 slides XML`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
## 机器优先流程
```bash
python3 skills/lark-slides/scripts/iconpark_tool.py search --query "增长趋势" --limit 8
python3 skills/lark-slides/scripts/iconpark_tool.py resolve --name chart-line
python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
```
`search` 返回 JSON 数组,每项包含 `iconType``category``name``tags``score`。直接把选中的 `iconType` 写入 XML并为图标指定可见颜色
```xml
<icon iconType="iconpark/Charts/chart-line.svg" topLeftX="80" topLeftY="120" width="32" height="32">
<fill>
<fillColor color="rgba(37, 99, 235, 1)"/>
</fill>
</icon>
```
## 使用规则
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8``--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
- 常用尺寸:行内状态图标 16-24px卡片标题图标 28-40px主视觉图标 56-96px。
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback不留空图标位。
## 高频示例
| 语义 | iconType |
|---|---|
| 设置/配置 | `iconpark/Base/setting.svg` |
| 目标 | `iconpark/Base/aiming.svg` |
| 增长趋势 | `iconpark/Charts/positive-dynamics.svg` |
| 折线趋势 | `iconpark/Charts/chart-line.svg` |
| 占比 | `iconpark/Charts/chart-proportion.svg` |
| 数据看板 | `iconpark/Charts/data-screen.svg` |
| 成功 | `iconpark/Character/check-one.svg` |
| 失败/风险 | `iconpark/Character/close-one.svg` |
| 团队/用户 | `iconpark/Peoples/peoples.svg` |
| 安全防护 | `iconpark/Safe/protect.svg` |
| 全球/市场 | `iconpark/Travel/world.svg` |
| 邮件/联系 | `iconpark/Office/envelope-one.svg` |

View File

@@ -84,7 +84,7 @@ lark-cli slides +replace-slide --as user \
| `<line>` | 直线 | 需 `startX/startY/endX/endY` |
| `<polyline>` | 折线 | `points` 读回时被服务端规整丢弃(几何已入库) |
| `<img>` | 图片 | `src` 必须是 [`+media-upload`](lark-slides-media-upload.md) 返回的 `file_token`,不能是 URL |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源 |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源;语义图标先用 `scripts/iconpark_tool.py search` 检索 |
| `<table>` | 表格 | 整表替换会**重建内部 td id**,旧 td block_id 立即失效 |
| `<td>` | 单元格局部替换 | 只能 `block_replace`,不能 `block_insert``block_id` 必须是最新 `slide.get` 拿到的 td id |
| `<chart>` | 图表line/bar/column/pie/area/radar/combo | 必须嵌 `<chartPlotArea>` + `<chartData>` + `<dim1>/<dim2>/<chartField>` |

View File

@@ -142,6 +142,8 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="120" width="32" height="32"/>
```
`iconType` 必须来自已验证的 IconPark 路径。需要语义图标时,先运行 `scripts/iconpark_tool.py search --query "<语义>"`,不要凭记忆拼路径。更多规则见 [iconpark.md](iconpark.md)。
### whiteboard
```xml

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