Compare commits

..

1 Commits

Author SHA1 Message Date
zhangheng.023
77ad271fce fix: write skills state timestamps in local time without timezone
skills-state.json updated_at now uses a YYYY-MM-DDTHH:mm:ss local wall-clock layout with no timezone suffix, applied in both the incremental sync and full-install fallback write paths via a shared layout constant. Unit tests assert the no-suffix format on both paths.
2026-06-12 18:01:58 +08:00
64 changed files with 3771 additions and 2486 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/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper enforced on domains whose shared validation/save
# helpers have migrated to typed final errors.
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/)
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/drive/|shortcuts/mail/|shortcuts/okr/|shortcuts/task/|shortcuts/whiteboard/|shortcuts/im/)
text: errs-no-legacy-helper
linters:
- forbidigo

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,8 @@ import (
"github.com/larksuite/cli/internal/selfupdate"
)
const skillsStateUpdatedAtLayout = "2006-01-02T15:04:05"
var (
skillNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_:-]*(@[^\s]+)?$`)
ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
@@ -335,7 +337,7 @@ func SyncSkills(opts SyncOptions) *SyncResult {
UpdatedSkills: plan.ToUpdate,
AddedOfficialSkills: plan.Added,
SkippedDeletedSkills: plan.SkippedDeleted,
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
UpdatedAt: opts.Now().Format(skillsStateUpdatedAtLayout),
}
if err := WriteState(state); err != nil {
result.Action = "failed"
@@ -428,7 +430,7 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
UpdatedSkills: official,
AddedOfficialSkills: official,
SkippedDeletedSkills: []string{},
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
UpdatedAt: opts.Now().Format(skillsStateUpdatedAtLayout),
}
if writeErr := WriteState(state); writeErr != nil {
return &SyncResult{

View File

@@ -339,7 +339,9 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time { return time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) },
Now: func() time.Time {
return time.Date(2026, 5, 18, 12, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60))
},
})
if result.Err != nil {
@@ -361,6 +363,9 @@ func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
assertStrings(t, state.UpdatedSkills, []string{"lark-calendar", "lark-new"})
assertStrings(t, state.AddedOfficialSkills, []string{"lark-new"})
assertStrings(t, state.SkippedDeletedSkills, []string{"lark-mail"})
if state.UpdatedAt != "2026-05-18T12:00:00" {
t.Errorf("state.UpdatedAt = %q, want local wall-clock timestamp without timezone", state.UpdatedAt)
}
if _, err := os.Stat(filepath.Join(dir, "skills.stamp")); !os.IsNotExist(err) {
t.Fatalf("skills.stamp exists or stat failed with unexpected err: %v", err)
}
@@ -584,7 +589,13 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
globalOut: "Some unrecognized output format\n",
}
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
result := SyncSkills(SyncOptions{
Version: "1.0.33",
Runner: runner,
Now: func() time.Time {
return time.Date(2026, 5, 18, 12, 0, 0, 0, time.FixedZone("UTC-7", -7*60*60))
},
})
if result.Action != "fallback_synced" {
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
}
@@ -594,6 +605,13 @@ func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
if runner.installedAll != 1 {
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
}
state, readable, err := ReadState()
if err != nil || !readable {
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
}
if state.UpdatedAt != "2026-05-18T12:00:00" {
t.Errorf("state.UpdatedAt = %q, want local wall-clock timestamp without timezone", state.UpdatedAt)
}
}
func TestSyncSkills_EmptyToUpdateFallsBackToFullInstall(t *testing.T) {

View File

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

View File

@@ -20,10 +20,8 @@ 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.CallAPITyped(
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,

View File

@@ -10,7 +10,6 @@ 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"
@@ -104,13 +103,6 @@ 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,6 +106,25 @@ 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,13 +5,35 @@ package doc
import (
"context"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1CreateFlags returns hidden parse-only compatibility flags for old v1 commands.
// v1CreateFlags returns the flag definitions for the v1 (MCP) create path.
func v1CreateFlags() []common.Flag {
return docsLegacyFlagDefinitions(docsCreateLegacyFlags())
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") != ""
}
var DocsCreate = common.Shortcut{
@@ -21,25 +43,213 @@ var DocsCreate = common.Shortcut{
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"docx:document:create"},
PostMount: installDocsShortcutHelp("+create"),
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
docsAPIVersionCompatFlag(),
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
},
v2CreateFlags(),
v1CreateFlags(),
v2CreateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateCreateV2(ctx, runtime)
if useV2Create(runtime) {
return validateCreateV2(ctx, runtime)
}
return validateCreateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunCreateV2(ctx, runtime)
if useV2Create(runtime) {
return dryRunCreateV2(ctx, runtime)
}
return dryRunCreateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeCreateV2(ctx, runtime)
if useV2Create(runtime) {
return executeCreateV2(ctx, runtime)
}
return executeCreateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsCreateFlagVersions)
},
}
// ── 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
@@ -48,3 +258,15 @@ 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,6 +48,7 @@ func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
@@ -248,63 +249,148 @@ func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
}
}
func TestDocsCreateAPIVersionV1StillUsesV2Endpoint(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) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
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",
},
registerDocsCreateMCPStub(reg, map[string]interface{}{
"doc_id": "doxcn_new_doc",
"message": "文档创建成功",
// "doc_url" deliberately omitted to exercise the fallback.
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--content", "<title>项目计划</title>",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
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)
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)
}
}
func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
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": "文档创建成功",
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v1",
"--title", "项目计划",
"--markdown", "## 目标",
"--as", "user",
})
if err == nil {
t.Fatal("expected legacy v1 flags to be rejected")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
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)
}
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)
}
}
@@ -335,6 +421,24 @@ 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,17 +13,14 @@ import (
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
func v2CreateFlags() []common.Flag {
return []common.Flag{
{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"},
{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},
}
}
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,13 +5,40 @@ package doc
import (
"context"
"fmt"
"io"
"strconv"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1FetchFlags returns hidden parse-only compatibility flags for old v1 commands.
// v1FetchFlags returns the flag definitions for the v1 (MCP) fetch path.
func v1FetchFlags() []common.Flag {
return docsLegacyFlagDefinitions(docsFetchLegacyFlags())
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
}
var DocsFetch = common.Shortcut{
@@ -22,22 +49,88 @@ var DocsFetch = common.Shortcut{
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
PostMount: installDocsShortcutHelp("+fetch"),
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
docsAPIVersionCompatFlag(),
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v2FetchFlags(),
v1FetchFlags(),
v2FetchFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateFetchV2(ctx, runtime)
if useV2Fetch(runtime) {
return validateFetchV2(ctx, runtime)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunFetchV2(ctx, runtime)
if useV2Fetch(runtime) {
return dryRunFetchV2(ctx, runtime)
}
return dryRunFetchV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeFetchV2(ctx, runtime)
if useV2Fetch(runtime) {
return executeFetchV2(ctx, runtime)
}
return executeFetchV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsFetchFlagVersions)
},
}
// ── 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: "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"},
{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"},
}
}
@@ -33,9 +33,6 @@ 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,7 +5,6 @@ package doc
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
@@ -59,82 +58,6 @@ 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", "")
@@ -150,37 +73,6 @@ 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,13 +5,57 @@ package doc
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/shortcuts/common"
)
// v1UpdateFlags returns hidden parse-only compatibility flags for old v1 commands.
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.
func v1UpdateFlags() []common.Flag {
return docsLegacyFlagDefinitions(docsUpdateLegacyFlags())
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") != ""
}
var DocsUpdate = common.Shortcut{
@@ -21,22 +65,225 @@ var DocsUpdate = common.Shortcut{
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+update"),
Tips: docsVersionSelectionTips,
Flags: concatFlags(
[]common.Flag{
docsAPIVersionCompatFlag(),
{Name: "api-version", Desc: "API version", Default: "v1", Enum: []string{"v1", "v2"}},
{Name: "doc", Desc: "document URL or token", Required: true},
},
v2UpdateFlags(),
v1UpdateFlags(),
v2UpdateFlags(),
),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateUpdateV2(ctx, runtime)
if useV2Update(runtime) {
return validateUpdateV2(ctx, runtime)
}
return validateUpdateV1(ctx, runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return dryRunUpdateV2(ctx, runtime)
if useV2Update(runtime) {
return dryRunUpdateV2(ctx, runtime)
}
return dryRunUpdateV1(ctx, runtime)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeUpdateV2(ctx, runtime)
if useV2Update(runtime) {
return executeUpdateV2(ctx, runtime)
}
return executeUpdateV1(ctx, runtime)
},
PostMount: func(cmd *cobra.Command) {
installVersionedHelp(cmd, "v1", docsUpdateFlagVersions)
},
}
// ── 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

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

@@ -0,0 +1,375 @@
// 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,12 +3,9 @@
package doc
import (
"context"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ── V2 tests ──
@@ -34,102 +31,199 @@ func TestValidCommandsV2(t *testing.T) {
}
}
func TestDocsUpdateDryRunAcceptsDeprecatedAPIVersionValues(t *testing.T) {
for _, apiVersion := range []string{"v1", "v2"} {
t.Run(apiVersion, func(t *testing.T) {
t.Parallel()
// ── V1 tests ──
runtime := newUpdateShortcutTestRuntime(t, apiVersion, nil)
if err := validateUpdateV2(context.Background(), runtime); err != nil {
t.Fatalf("validateUpdateV2() error = %v", err)
}
func TestSelectionRequiredMessageV1ReplaceAllSuggestsOverwrite(t *testing.T) {
t.Parallel()
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)
}
})
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)
}
}
}
func TestDocsUpdateRejectsLegacyFlags(t *testing.T) {
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()
tests := []struct {
name string
setFlags map[string]string
want []string
markdown string
wantWarn bool
wantSubs []string
}{
{
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",
},
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"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags)
err := validateUpdateV2(context.Background(), runtime)
if err == nil {
t.Fatal("expected v2-only validation error")
got := checkOverwriteResourceBlocks(tt.markdown)
if (got != "") != tt.wantWarn {
t.Fatalf("checkOverwriteResourceBlocks(%q) = %q, wantWarn=%v", tt.markdown, got, tt.wantWarn)
}
for _, want := range tt.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q: %v", want, err)
for _, sub := range tt.wantSubs {
if !strings.Contains(got, sub) {
t.Errorf("expected warning to contain %q, got: %s", sub, got)
}
}
})
}
}
func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext {
t.Helper()
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,
}
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"])
}
}
for name, value := range setFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set %s: %v", name, err)
if len(got) != 0 {
t.Fatalf("expected empty board_tokens, got %#v", got)
}
}
return common.TestNewRuntimeContext(cmd, nil)
})
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)
}
})
}
}

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; 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"},
{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"},
}
}
@@ -39,9 +39,6 @@ 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

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

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

@@ -0,0 +1,569 @@
// 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,44 +9,28 @@ import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
const docsServiceHelpDefault = `Document and content operations.`
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"
const docsServiceHelpV2 = `Document and content operations (v2).`
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
}
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.",
}
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"
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
}
return docsVersionSelectionTips
}
// Shortcuts returns all docs shortcuts.
@@ -64,32 +48,45 @@ 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
}
cmd.Long = docsHelpLong(docsServiceHelpDefault, docsSkillReadCommand)
}
func installDocsShortcutHelp(command string) func(*cobra.Command) {
return func(cmd *cobra.Command) {
cmd.Long = docsHelpLong(cmd.Short, docsSkillReadCommandForShortcut(command))
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
})
}
}
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))
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)
}
})
}

View File

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

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

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

@@ -0,0 +1,36 @@
// 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,19 +8,11 @@ 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",
@@ -43,15 +35,32 @@ var DriveInspect = common.Shortcut{
},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := driveInspectResolveRef(runtime); err != nil {
return err
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")
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, err := driveInspectResolveRef(runtime)
if err != nil {
return common.NewDryRunAPI()
raw := strings.TrimSpace(runtime.Str("url"))
ref, ok := common.ParseResourceURL(raw)
if !ok {
ref = common.ResourceRef{
Type: strings.TrimSpace(runtime.Str("type")),
Token: raw,
}
}
dry := common.NewDryRunAPI()
@@ -82,9 +91,15 @@ var DriveInspect = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
raw := strings.TrimSpace(runtime.Str("url"))
ref, err := driveInspectResolveRef(runtime)
if err != nil {
return err
// 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,
}
}
inputURL := raw
@@ -96,19 +111,14 @@ 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 := driveInspectCallWithRetry(
ctx,
func() (map[string]interface{}, error) {
return runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
)
},
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": docToken},
nil,
)
if err != nil {
return driveInspectAnnotateError("resolve_wiki", err)
return err
}
node := common.GetMap(data, "node")
@@ -135,9 +145,9 @@ var DriveInspect = common.Shortcut{
}
// Step 3: Call batch_query to verify and get title.
title, err := driveInspectFetchMetaTitle(ctx, runtime, docToken, docType)
title, err := common.FetchDriveMetaTitle(runtime, docToken, docType)
if err != nil {
return driveInspectAnnotateError("query_meta", err)
return err
}
// Step 4: Build the resolved URL.
@@ -171,116 +181,3 @@ 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,13 +6,10 @@ 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"
@@ -86,34 +83,6 @@ 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", "", "")
@@ -571,76 +540,6 @@ 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

@@ -17,7 +17,6 @@ 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"
@@ -55,29 +54,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens is required").WithParam("--minute-tokens")
return output.ErrValidation("--minute-tokens is required")
}
if len(tokens) > maxBatchSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize).WithParam("--minute-tokens")
return output.ErrValidation("--minute-tokens: too many tokens (%d), maximum is %d", len(tokens), maxBatchSize)
}
for _, token := range tokens {
if !validMinuteToken.MatchString(token) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token).WithParam("--minute-tokens")
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters (e.g. obcnq3b9jl72l83w4f149w9c)", token)
}
}
// Cheap checks first, then path-safety resolution.
out := runtime.Str("output")
outDir := runtime.Str("output-dir")
if out != "" && outDir != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --output-dir cannot both be set").WithParam("--output")
return output.ErrValidation("--output and --output-dir cannot both be set")
}
if out != "" {
if err := common.ValidateSafePathTyped(runtime.FileIO(), out); err != nil {
if err := common.ValidateSafePath(runtime.FileIO(), out); err != nil {
return err
}
}
if outDir != "" {
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
return err
}
}
@@ -113,7 +112,7 @@ var MinutesDownload = common.Shortcut{
explicitOutputPath = ""
case statErr == nil && !fi.IsDir():
if !single {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath).WithParam("--output")
return output.ErrValidation("--output %q is a file; batch mode expects a directory (use --output-dir)", explicitOutputPath)
}
case errors.Is(statErr, fs.ErrNotExist):
if !single {
@@ -121,7 +120,7 @@ var MinutesDownload = common.Shortcut{
explicitOutputPath = ""
}
default:
return errs.NewInternalError(errs.SubtypeFileIO, "cannot access --output %q: %s", explicitOutputPath, statErr).WithCause(statErr)
return output.Errorf(output.ExitAPI, "io_error", "cannot access --output %q: %s", explicitOutputPath, statErr)
}
}
@@ -138,7 +137,6 @@ 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))
@@ -153,18 +151,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 errs.NewNetworkError(errs.SubtypeNetworkTransport, "failed to get HTTP client: %s", err).WithCause(err)
return output.ErrNetwork("failed to get HTTP client: %s", 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") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
return fmt.Errorf("too many redirects")
}
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") //nolint:forbidigo // returned to net/http CheckRedirect, not a CLI terminal error
return fmt.Errorf("redirect from https to http is not allowed")
}
}
return validate.ValidateDownloadSourceURL(req.Context(), req.URL.String())
@@ -195,7 +193,7 @@ var MinutesDownload = common.Shortcut{
downloadURL, err := fetchDownloadURL(ctx, runtime, token)
if err != nil {
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
results[i] = result{MinuteToken: token, Error: err.Error()}
continue
}
@@ -222,7 +220,7 @@ var MinutesDownload = common.Shortcut{
dl, err := downloadMediaFile(ctx, dlClient, downloadURL, token, opts)
if err != nil {
results[i] = result{MinuteToken: token, Error: err.Error(), err: err}
results[i] = result{MinuteToken: token, Error: err.Error()}
continue
}
results[i] = result{
@@ -237,10 +235,7 @@ var MinutesDownload = common.Shortcut{
if single {
r := results[0]
if r.Error != "" {
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)})
return output.ErrAPI(0, r.Error, nil)
}
if urlOnly {
runtime.Out(map[string]interface{}{
@@ -267,19 +262,17 @@ var MinutesDownload = common.Shortcut{
}
fmt.Fprintf(errOut, "[minutes +download] done: %d total, %d succeeded, %d failed\n", len(results), successCount, len(results)-successCount)
outData := map[string]interface{}{"downloads": results}
meta := &output.Meta{Count: len(results)}
runtime.OutFormat(map[string]interface{}{"downloads": results}, &output.Meta{Count: len(results)}, nil)
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(outData, meta)
return output.ErrAPI(0, fmt.Sprintf("all %d downloads failed", len(results)), nil)
}
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.CallAPITyped(http.MethodGet,
data, err := runtime.DoAPIJSON(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/media", validate.EncodePathSegment(minuteToken)),
nil, nil)
if err != nil {
@@ -287,7 +280,7 @@ func fetchDownloadURL(ctx context.Context, runtime *common.RuntimeContext, minut
}
downloadURL := common.GetString(data, "download_url")
if downloadURL == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned empty download_url for %s", minuteToken)
return "", output.Errorf(output.ExitAPI, "api_error", "API returned empty download_url for %s", minuteToken)
}
return downloadURL, nil
}
@@ -309,26 +302,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, errs.NewValidationError(errs.SubtypeInvalidArgument, "blocked download URL: %s", err).WithCause(err)
return nil, output.ErrValidation("blocked download URL: %s", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "invalid download URL: %s", err).WithCause(err)
return nil, output.ErrNetwork("invalid download URL: %s", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: %s", err).WithCause(err)
return nil, output.ErrNetwork("download failed: %s", 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, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
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", resp.StatusCode)
return nil, output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
}
// resolve output path
@@ -347,7 +340,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
if !opts.overwrite {
if _, statErr := opts.fio.Stat(outputPath); statErr == nil {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", outputPath)
return nil, output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath)
}
}
@@ -356,7 +349,7 @@ func downloadMediaFile(ctx context.Context, client *http.Client, downloadURL, mi
ContentLength: resp.ContentLength,
}, resp.Body)
if err != nil {
return nil, common.WrapSaveErrorTyped(err)
return nil, common.WrapSaveErrorByCategory(err, "io")
}
resolvedPath, err := opts.fio.ResolvePath(outputPath)
if err != nil || resolvedPath == "" {

View File

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

View File

@@ -6,11 +6,9 @@ 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"
@@ -65,11 +63,10 @@ func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
start string
end string
wantMessage string
wantParam string
}{
{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"},
{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"},
}
for _, tt := range tests {
@@ -91,16 +88,6 @@ 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)
}
})
}
}
@@ -222,16 +209,6 @@ 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)
}
})
}
}
@@ -290,13 +267,6 @@ 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.
@@ -309,16 +279,6 @@ 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.
@@ -331,16 +291,6 @@ 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.
@@ -356,16 +306,6 @@ 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.
@@ -395,16 +335,6 @@ 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.
@@ -442,13 +372,6 @@ 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,11 +5,12 @@ package minutes
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -36,27 +37,27 @@ var MinutesSpeakerReplace = common.Shortcut{
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
if minuteToken == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--minute-token is required").WithParam("--minute-token")
return output.ErrValidation("--minute-token is required")
}
if err := validate.ResourceName(minuteToken, "--minute-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--minute-token")
return output.ErrValidation("%s", err)
}
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id is required").WithParam("--from-user-id")
return output.ErrValidation("--from-user-id is required")
}
if _, err := common.ValidateUserIDTyped("--from-user-id", fromUserID); err != nil {
return err
if _, err := common.ValidateUserID(fromUserID); err != nil {
return output.ErrValidation("--from-user-id: %s", err)
}
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
if toUserID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--to-user-id is required").WithParam("--to-user-id")
return output.ErrValidation("--to-user-id is required")
}
if _, err := common.ValidateUserIDTyped("--to-user-id", toUserID); err != nil {
return err
if _, err := common.ValidateUserID(toUserID); err != nil {
return output.ErrValidation("--to-user-id: %s", err)
}
if fromUserID == toUserID {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--from-user-id and --to-user-id must be different").WithParam("--to-user-id")
return output.ErrValidation("--from-user-id and --to-user-id must be different")
}
return nil
},
@@ -83,7 +84,7 @@ var MinutesSpeakerReplace = common.Shortcut{
"to_user_id": toUserID,
}
_, err := runtime.CallAPITyped(http.MethodPut,
_, err := runtime.CallAPI(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
nil, body)
if err != nil {
@@ -102,18 +103,37 @@ var MinutesSpeakerReplace = common.Shortcut{
}
func minutesSpeakerReplaceError(err error, minuteToken, fromUserID string) error {
p, ok := errs.ProblemOf(err)
if !ok {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
return err
}
switch p.Code {
switch exitErr.Detail.Code {
case minutesSpeakerReplaceNoEditPermission:
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"
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,
}
case minutesSpeakerReplaceSpeakerNotFoundCode:
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 &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,
}
}
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: "invalid user ID format",
wantErr: "--from-user-id",
},
{
name: "invalid to prefix",
args: []string{"+speaker-replace", "--minute-token", "obcn123456", "--from-user-id", "ou_a", "--to-user-id", "u_b", "--as", "user"},
wantErr: "invalid user ID format",
wantErr: "--to-user-id",
},
{
name: "from equals to",
@@ -76,52 +76,6 @@ 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())
@@ -225,21 +179,24 @@ func TestMinutesSpeakerReplace_SpeakerNotFound(t *testing.T) {
t.Fatal("expected speaker-not-found error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed errs.*, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypeNotFound {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeNotFound)
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if !strings.Contains(p.Message, "Speaker not found") {
t.Errorf("message should be friendly, got: %s", p.Message)
if exitErr.Detail.Type != "speaker_not_found" {
t.Errorf("error type = %q, want speaker_not_found", exitErr.Detail.Type)
}
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, "Speaker not found") {
t.Errorf("message should be friendly, got: %s", exitErr.Detail.Message)
}
if !strings.Contains(p.Hint, "--from-user-id") {
t.Errorf("hint should mention --from-user-id, got: %s", p.Hint)
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)
}
}
@@ -268,20 +225,23 @@ func TestMinutesSpeakerReplace_NoEditPermission(t *testing.T) {
t.Fatal("expected no-edit-permission error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed errs.*, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if !strings.Contains(p.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", p.Message)
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
}
if !strings.Contains(p.Message, minutesSpeakerReplaceTestToken) {
t.Errorf("message should include minute token, 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.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
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)
}
}

View File

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

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,32 +55,6 @@ 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())
@@ -158,20 +132,23 @@ func TestMinutesUpdate_NoEditPermission(t *testing.T) {
t.Fatal("expected no-edit-permission error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("want typed errs.*, got %T: %v", err, err)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if p.Subtype != errs.SubtypePermissionDenied {
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
if exitErr.Detail == nil {
t.Fatalf("expected structured error detail, got nil")
}
if !strings.Contains(p.Message, "No edit permission") {
t.Errorf("message should be friendly, got: %s", p.Message)
if exitErr.Detail.Type != "no_edit_permission" {
t.Errorf("error type = %q, want no_edit_permission", exitErr.Detail.Type)
}
if !strings.Contains(p.Message, minutesUpdateTestToken) {
t.Errorf("message should include minute token, 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.Hint, "edit permission") {
t.Errorf("hint should mention edit permission, got: %s", p.Hint)
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)
}
}

View File

@@ -6,7 +6,7 @@ package minutes
import (
"context"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-token is required").WithParam("--file-token")
return output.ErrValidation("--file-token is required")
}
if err := validate.ResourceName(fileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")
return output.ErrValidation("%s", err)
}
return nil
},
@@ -55,7 +55,7 @@ var MinutesUpload = common.Shortcut{
"file_token": fileToken,
}
data, err := runtime.CallAPITyped("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
data, err := runtime.CallAPI("POST", "/open-apis/minutes/v1/minutes/upload", nil, body)
if err != nil {
return err
}

View File

@@ -5,12 +5,10 @@ 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"
@@ -48,31 +46,6 @@ 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 TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
func TestRegisterShortcutsDocsHelpAddsVersionSelectorAndUpgradeTips(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -166,111 +166,121 @@ func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(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 not expose service-level --api-version")
if docsCmd.Flags().Lookup("api-version") == nil {
t.Fatal("docs command should expose --api-version for versioned help")
}
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{
"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",
"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",
} {
if !strings.Contains(defaultHelp.String(), want) {
t.Fatalf("docs default help missing %q:\n%s", want, defaultHelp.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())
}
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())
}
}
for _, unwanted := range []string{
"Tips:",
"+get-skill",
"Docs shortcuts are v2-only",
"Docs v1 is deprecated and will be removed soon",
"lark-cli update",
"upgrade skills",
"Use --api-version v2 for the latest API",
"After confirming lark-doc is v2",
"use `--api-version v2` with docs +create, docs +fetch, and docs +update",
} {
if strings.Contains(defaultHelp.String(), unwanted) {
t.Fatalf("docs help should not include %q:\n%s", unwanted, defaultHelp.String())
if strings.Contains(out.String(), unwanted) {
t.Fatalf("docs v2 help should not include %q:\n%s", unwanted, out.String())
}
}
}
func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
func TestRegisterShortcutsDocsVersionedShortcutHelpAddsVersionTips(t *testing.T) {
tests := []struct {
name string
shortcut string
shortcutHelp string
visibleFlag string
skillCommand string
hiddenFlags []string
contentHelp []string
unwanted []string
name string
shortcut string
apiVersion string
shortcutHelp string
versionedFlag string
}{
{
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 v1",
shortcut: "+create",
apiVersion: "v1",
shortcutHelp: "Create a Lark document",
versionedFlag: "--markdown",
},
{
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: "create v2",
shortcut: "+create",
apiVersion: "v2",
shortcutHelp: "Create a Lark document",
versionedFlag: "--content",
},
{
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"},
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",
},
}
@@ -286,25 +296,8 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(t *testing.T) {
if cmd == nil || cmd.Name() != tt.shortcut {
t.Fatalf("docs %s shortcut not mounted: %#v", tt.shortcut, cmd)
}
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)
if err := cmd.Flags().Set("api-version", tt.apiVersion); err != nil {
t.Fatalf("set docs %s api-version: %v", tt.shortcut, err)
}
var out bytes.Buffer
@@ -313,39 +306,49 @@ func TestRegisterShortcutsDocsShortcutHelpIsV2Only(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.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,
tt.versionedFlag,
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s help missing %q:\n%s", tt.shortcut, want, out.String())
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
for _, want := range tt.contentHelp {
for _, want := range wantTips {
if !strings.Contains(out.String(), want) {
t.Fatalf("docs %s content help missing %q:\n%s", tt.shortcut, want, out.String())
t.Fatalf("docs %s %s help missing %q:\n%s", tt.shortcut, tt.apiVersion, want, out.String())
}
}
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"} {
for _, unwanted := range unwantedTips {
if strings.Contains(out.String(), unwanted) {
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())
t.Fatalf("docs %s %s help should not include %q:\n%s", tt.shortcut, tt.apiVersion, unwanted, out.String())
}
}
})

View File

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

View File

@@ -5,7 +5,6 @@ package vc
import (
"context"
"errors"
"reflect"
"strings"
"testing"
@@ -13,10 +12,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 {
@@ -324,19 +323,19 @@ func TestBuildMeetingEventsParams(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["meeting_id"]; got != "7628568141510692381" {
if got := params["meeting_id"][0]; got != "7628568141510692381" {
t.Fatalf("meeting_id = %q, want %q", got, "7628568141510692381")
}
if got := params["page_size"]; got != "40" {
if got := params["page_size"][0]; got != "40" {
t.Fatalf("page_size = %q, want %q", got, "40")
}
if got := params["page_token"]; got != "1710000000000000000" {
if got := params["page_token"][0]; got != "1710000000000000000" {
t.Fatalf("page_token = %q, want %q", got, "1710000000000000000")
}
if got := params["start_time"]; got != "1710000000" {
if got := params["start_time"][0]; got != "1710000000" {
t.Fatalf("start_time = %q, want %q", got, "1710000000")
}
if got := params["end_time"]; got != "1710003600" {
if got := params["end_time"][0]; got != "1710003600" {
t.Fatalf("end_time = %q, want %q", got, "1710003600")
}
}
@@ -350,7 +349,7 @@ func TestBuildMeetingEventsParams_PageSizeBelowMinClampsToMin(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"]; got != "20" {
if got := params["page_size"][0]; got != "20" {
t.Fatalf("page_size = %q, want %q when below min", got, "20")
}
}
@@ -364,7 +363,7 @@ func TestBuildMeetingEventsParams_PageSizeAboveMaxClampsToMax(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"]; got != "100" {
if got := params["page_size"][0]; got != "100" {
t.Fatalf("page_size = %q, want %q when above max", got, "100")
}
}
@@ -379,7 +378,7 @@ func TestBuildMeetingEventsParams_PageAllUsesMaxPageSize(t *testing.T) {
if err != nil {
t.Fatalf("buildMeetingEventsParams() error = %v", err)
}
if got := params["page_size"]; got != "100" {
if got := params["page_size"][0]; got != "100" {
t.Fatalf("page_size = %q, want %q when page-all is set", got, "100")
}
}
@@ -747,30 +746,22 @@ func TestFormatTimelineOffset(t *testing.T) {
}
func TestFlattenQueryParams(t *testing.T) {
params := map[string]interface{}{
"one": "1",
"many": "2",
params := larkcore.QueryParams{
"one": []string{"1"},
"many": []string{"2", "3"},
"empty": []string{},
}
got := flattenQueryParams(params)
want := map[string]interface{}{
"one": "1",
"many": "2",
"many": []string{"2", "3"},
}
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{}{},
@@ -938,79 +929,3 @@ 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,7 +10,6 @@ import (
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -38,7 +37,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--meeting-number must be exactly 9 digits, got %q", mn).WithParam("--meeting-number")
return common.FlagErrorf("--meeting-number must be exactly 9 digits, got %q", mn)
}
return nil
},
@@ -50,7 +49,7 @@ var VCMeetingJoin = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body := buildMeetingJoinBody(runtime)
data, err := runtime.CallAPITyped("POST", "/open-apis/vc/v1/bots/join", nil, body)
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/bots/join", nil, body)
if err != nil {
return err
}

View File

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

View File

@@ -7,13 +7,11 @@ 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"
@@ -579,67 +577,7 @@ func TestMeetingLeave_Execute_APIError(t *testing.T) {
if err == nil {
t.Fatal("expected error for API failure")
}
// 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")
if !strings.Contains(err.Error(), "no permission") {
t.Errorf("error should surface API message, got: %v", err)
}
}

View File

@@ -23,6 +23,8 @@ 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"
@@ -72,13 +74,22 @@ const (
)
func minutesReadError(err error, minuteToken string) error {
p, ok := errs.ProblemOf(err)
if !ok || p.Code != minutesNoReadPermissionCode {
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Code != minutesNoReadPermissionCode {
return 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
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,
}
}
// validMinuteToken matches the server's minute-token format and blocks any
@@ -103,19 +114,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.CallAPITyped(http.MethodPost, "/open-apis/calendar/v4/calendars/primary", nil, nil)
data, err := runtime.DoAPIJSON(http.MethodPost, "/open-apis/calendar/v4/calendars/primary", nil, nil)
if err != nil {
return "", err
return "", err // preserve original API error (with lark error code)
}
calendars, _ := data["calendars"].([]any)
if len(calendars) == 0 {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "primary calendar not found")
return "", output.ErrValidation("primary calendar not found")
}
first, _ := calendars[0].(map[string]any)
cal, _ := first["calendar"].(map[string]any)
calID, _ := cal["calendar_id"].(string)
if calID == "" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "primary calendar ID is empty")
return "", output.ErrValidation("primary calendar ID is empty")
}
return calID, nil
}
@@ -138,23 +149,23 @@ func resolveMeetingIDsFromCalendarEvent(runtime *common.RuntimeContext, instance
if needNotes {
body["need_meeting_notes"] = true
}
data, err := runtime.CallAPITyped(http.MethodPost,
data, err := runtime.DoAPIJSON(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, err
return nil, fmt.Errorf("failed to query event relation info: %w", err)
}
infos, _ := data["instance_relation_infos"].([]any)
if len(infos) == 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "no event relation info found")
return nil, fmt.Errorf("no event relation info found")
}
info, _ := infos[0].(map[string]any)
rawIDs, _ := info["meeting_instance_ids"].([]any)
if len(rawIDs) == 0 {
return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "no associated video meeting for this event")
return nil, fmt.Errorf("no associated video meeting for this event")
}
result := &eventRelationInfo{}
@@ -282,12 +293,13 @@ 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.CallAPITyped(http.MethodGet,
data, err := runtime.DoAPIJSON(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
if p, ok := errs.ProblemOf(err); ok {
switch p.Code {
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil {
switch exitErr.Detail.Code {
case recordingNotFoundCode:
return "", "no minute file for this meeting"
case recordingNoPermissionCode:
@@ -316,8 +328,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.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/meetings/%s", validate.EncodePathSegment(meetingID)),
map[string]interface{}{"with_participants": "false", "query_mode": "0"}, nil)
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)
if err != nil {
return map[string]any{"meeting_id": meetingID, "error": fmt.Sprintf("failed to query meeting: %v", err)}
}
@@ -387,12 +399,13 @@ 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.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
data, err := runtime.DoAPIJSON(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()}
if p, ok := errs.ProblemOf(err); ok && p.Hint != "" {
result["hint"] = p.Hint
var exitErr *output.ExitError
if errors.As(err, &exitErr) && exitErr.Detail != nil && exitErr.Detail.Hint != "" {
result["hint"] = exitErr.Detail.Hint
}
return result
}
@@ -456,7 +469,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.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
data, err := runtime.DoAPIJSON(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
@@ -569,10 +582,11 @@ 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.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
data, err := runtime.DoAPIJSON(http.MethodGet, fmt.Sprintf("/open-apis/vc/v1/notes/%s", validate.EncodePathSegment(noteID)), nil, nil)
if err != nil {
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)}
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)}
}
return map[string]any{"error": fmt.Sprintf("failed to query note detail: %v", err)}
}
@@ -616,7 +630,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.ExactlyOneTyped(runtime, "meeting-ids", "minute-tokens", "calendar-event-ids"); err != nil {
if err := common.ExactlyOne(runtime, "meeting-ids", "minute-tokens", "calendar-event-ids"); err != nil {
return err
}
// batch input size limit
@@ -624,12 +638,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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize).WithParam("--" + flag)
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
}
}
}
if outDir := runtime.Str("output-dir"); outDir != "" {
if err := common.ValidateSafePathTyped(runtime.FileIO(), outDir); err != nil {
if err := common.ValidateSafePath(runtime.FileIO(), outDir); err != nil {
return err
}
}
@@ -637,7 +651,7 @@ var VCNotes = common.Shortcut{
if v := runtime.Str("minute-tokens"); v != "" {
for _, token := range common.SplitCSV(v) {
if !validMinuteToken.MatchString(token) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid minute token %q: must contain only lowercase alphanumeric characters", token).WithParam("--minute-tokens")
return output.ErrValidation("invalid minute token %q: must contain only lowercase alphanumeric characters", token)
}
}
}
@@ -758,7 +772,9 @@ var VCNotes = common.Shortcut{
// all failed → return structured error
if successCount == 0 && len(results) > 0 {
return runtime.OutPartialFailure(map[string]any{"notes": results}, &output.Meta{Count: len(results)})
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)
}
// output

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
@@ -17,11 +16,9 @@ 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"
)
@@ -252,25 +249,11 @@ 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) {
@@ -1023,18 +1006,11 @@ 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 OutPartialFailure.
// Two-path failure with no payload should make the batch return ErrAPI.
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")
@@ -1068,13 +1044,6 @@ 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")
@@ -1084,249 +1053,3 @@ 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,7 +18,6 @@ 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"
@@ -56,7 +55,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.CallAPITyped(http.MethodGet,
data, err := runtime.DoAPIJSON(http.MethodGet,
fmt.Sprintf("/open-apis/vc/v1/meetings/%s/recording", validate.EncodePathSegment(meetingID)),
nil, nil)
if err != nil {
@@ -98,14 +97,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.ExactlyOneTyped(runtime, "meeting-ids", "calendar-event-ids"); err != nil {
if err := common.ExactlyOne(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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize).WithParam("--" + flag)
return output.ErrValidation("--%s: too many IDs (%d), maximum is %d", flag, len(ids), maxBatchSize)
}
}
}
@@ -122,11 +121,9 @@ var VCRecording = common.Shortcut{
stored := auth.GetStoredToken(appID, userOpenID)
if stored != nil {
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
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()))
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, " ")))
}
}
}
@@ -215,7 +212,9 @@ 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 {
return runtime.OutPartialFailure(map[string]any{"recordings": results}, &output.Meta{Count: len(results)})
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)
}
outData := map[string]any{"recordings": results}

View File

@@ -5,21 +5,14 @@ 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"
)
@@ -68,26 +61,12 @@ 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) {
@@ -103,16 +82,6 @@ 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) {
@@ -128,65 +97,6 @@ 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)
}
}
// ---------------------------------------------------------------------------
@@ -788,10 +698,8 @@ 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)
}
// 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 !strings.Contains(e2, "no permission") {
t.Errorf("m002 error should contain API message, got: %s", e2)
}
if r1["meeting_id"] != "m001" {
t.Errorf("error result should preserve meeting_id, got: %v", r1["meeting_id"])
@@ -865,59 +773,3 @@ 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) //nolint:forbidigo // intermediate parse error; callers wrap it into a typed ValidationError
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
}
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 "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start: %v", err).WithParam("--start")
return "", "", output.ErrValidation("--start: %v", err)
}
startTime = parsed
}
if end != "" {
parsed, err := toRFC3339(end, "end")
if err != nil {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
return "", "", output.ErrValidation("--end: %v", err)
}
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 "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--start (%s) is after --end (%s)", start, end).WithParam("--start")
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
}
}
return startTime, endTime, nil
@@ -138,16 +138,16 @@ func buildSearchBody(runtime *common.RuntimeContext, startTime, endTime string)
return body
}
func buildSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
func buildSearchParams(runtime *common.RuntimeContext) larkcore.QueryParams {
params := larkcore.QueryParams{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
pageSize, _ := strconv.Atoi(strings.TrimSpace(runtime.Str("page-size")))
if pageSize <= 0 {
pageSize = defaultVCSearchPageSize
}
params["page_size"] = strconv.Itoa(pageSize)
params["page_size"] = []string{strconv.Itoa(pageSize)}
if pageToken != "" {
params["page_token"] = pageToken
params["page_token"] = []string{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 errs.NewValidationError(errs.SubtypeInvalidArgument, "--query: length must be between 1 and 50 characters").WithParam("--query")
return output.ErrValidation("--query: length must be between 1 and 50 characters")
}
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", defaultVCSearchPageSize, 1, maxVCSearchPageSize); err != nil {
if _, err := common.ValidatePageSize(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 errs.NewValidationError(errs.SubtypeInvalidArgument, "specify at least one of --query, --start, --end, --organizer-ids, --participant-ids, or --room-ids")
return common.FlagErrorf("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,10 +210,20 @@ 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(params) > 0 {
dryRun.Params(params)
if len(dryRunParams) > 0 {
dryRun.Params(dryRunParams)
}
return dryRun.Body(buildSearchBody(runtime, startTime, endTime))
},
@@ -222,7 +232,7 @@ var VCSearch = common.Shortcut{
if err != nil {
return err
}
data, err := runtime.CallAPITyped("POST", "/open-apis/vc/v1/meetings/search", buildSearchParams(runtime), buildSearchBody(runtime, startTime, endTime))
data, err := runtime.DoAPIJSON("POST", "/open-apis/vc/v1/meetings/search", buildSearchParams(runtime), buildSearchBody(runtime, startTime, endTime))
if err != nil {
return err
}

View File

@@ -5,13 +5,11 @@ 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"
@@ -241,16 +239,6 @@ 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) {
@@ -271,182 +259,3 @@ 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

@@ -1,7 +1,6 @@
---
name: lark-doc
version: 2.0.0
description: "飞书云文档 / Docx / 知识库 Wiki 文档v2创建、打开、读取、获取、查看、总结、整理、改写、翻译、审阅和编辑飞书文档内容。当用户给出飞书文档 URL/token或说查看/读取/打开某个文档、提取文档内容、总结文档、生成/创建文档、追加/替换/删除/移动内容、调整排版、插入或下载文档图片/附件/素材/画板缩略图时使用。文档内容中出现嵌入电子表格、多维表格、需要将重要信息可视化为画板(含 SVG 画板)、引用或同步块时,也先用本 skill 读取和提取 token再切到对应 skill 下钻。默认使用 DocxXML也支持 Markdown。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。"
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而不是域名。"
metadata:
requires:
bins: ["lark-cli"]

View File

@@ -1,9 +1,10 @@
# docs +create创建飞书云文档
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 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、并行执行策略
> 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、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
@@ -84,3 +85,4 @@ 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,6 +1,8 @@
# docs +fetch获取飞书云文档
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
## 命令
```bash
@@ -134,4 +136,5 @@ 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-doc-media-download](lark-doc-media-download.md) — 下载素材/画板缩略图
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数

View File

@@ -69,7 +69,3 @@ 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,9 +2,10 @@
# docs +update更新飞书云文档
> **前置条件MUST READ** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
> 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、并行执行策略
> 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、并行执行策略
>
> **未读完以上文件就生成内容会导致格式错误或样式不达标。**
@@ -248,3 +249,4 @@ 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

@@ -9,7 +9,6 @@
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
- TestDocs_CreateAndFetchWorkflowAsUser: proves the same shortcut pair with UAT injection via `create as user` and `fetch as user`; creates its own Drive folder fixture first, then reads back the created doc by token.
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance.
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
@@ -17,11 +16,11 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create | `--parent-token`; `--doc-format markdown`; `--content` | helper asserts returned doc id from `data.document.document_id` |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown` | |
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user | `--folder-token`; `--title`; `--markdown` | helper asserts returned doc id |
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user | `--doc <docToken>` | |
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
| ✕ | docs +search | shortcut | | none | search results are ambient and not yet stabilized for E2E |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/update | `--doc`; `--command overwrite`; `--doc-format markdown`; `--content` | |
| ✓ | docs +update | shortcut | docs_update_test.go::TestDocs_UpdateWorkflow/update-title-and-content as bot | `--doc`; `--mode overwrite`; `--markdown`; `--new-title` | |
| ✕ | docs +whiteboard-update | shortcut | | none | requires whiteboard fixture and DSL-specific assertions |

View File

@@ -41,15 +41,12 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
Args: []string{
"docs", "+fetch",
"--doc", docToken,
"--doc-format", "markdown",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
assert.Contains(t, content, docTitle)
assert.Contains(t, content, "This document was created by lark-cli e2e test.")
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
})
}
@@ -76,14 +73,12 @@ func TestDocs_CreateAndFetchWorkflowAsUser(t *testing.T) {
require.NotEmpty(t, docToken, "document token should be created before fetch")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
Args: []string{"docs", "+fetch", "--doc", docToken},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
assert.Contains(t, content, docTitle)
assert.Contains(t, content, "Created with user access token.")
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -13,7 +13,20 @@ import (
"github.com/stretchr/testify/require"
)
func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
// TestDocs_UpdateDryRunSuppressesSemanticWarnings asserts the contract that
// docsUpdateWarnings is NOT invoked on the --dry-run path. The unit tests in
// shortcuts/doc/docs_update_check_test.go prove the helper emits warnings for
// replace_range + blank-line and for combined-emphasis markers; this E2E
// locks in that they never reach the user during dry-run planning, so a
// future refactor that moves warning emission into a shared code path can't
// silently regress.
//
// Input is intentionally crafted to trigger BOTH warnings the helper emits:
// - mode=replace_range + markdown containing "\n\n" (blank-line warning)
// - markdown containing `***combined***` (combined bold+italic warning)
//
// Neither string may appear in dry-run output.
func TestDocs_UpdateDryRunSuppressesSemanticWarnings(t *testing.T) {
// Fake creds are enough — dry-run short-circuits before any real API call.
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
@@ -22,76 +35,36 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
tests := []struct {
name string
args []string
wantURL string
}{
{
name: "create",
args: []string{
"docs", "+create",
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents",
},
{
name: "create api-version v1 compatibility",
args: []string{
"docs", "+create",
"--api-version", "v1",
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents",
},
{
name: "fetch",
args: []string{
"docs", "+fetch",
"--doc", "doxcnDryRunE2E",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
},
{
name: "update",
args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--command", "append",
"--content", "<p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
},
}
// "***combined***" is a triple-asterisk combined-emphasis shape; "\n\n"
// is a paragraph break. Both would normally produce warnings when
// Execute runs under --mode=replace_range; both must be absent here.
markdown := "***combined***\n\nsecond paragraph"
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", "doxcnDryRunE2E",
"--mode", "replace_range",
"--selection-with-ellipsis", "placeholder",
"--markdown", markdown,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
combined := result.Stdout + "\n" + result.Stderr
for _, want := range []string{
tt.wantURL,
"docs_ai/v1",
} {
if !strings.Contains(combined, want) {
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
}
}
if strings.Contains(combined, "/mcp") || strings.Contains(combined, "MCP tool") {
t.Fatalf("dry-run output should not use MCP\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
if strings.Contains(combined, "--api-version") {
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
})
// Neither warning prefix ("warning:") nor either specific warning body
// may appear in dry-run output (stdout OR stderr).
combined := result.Stdout + "\n" + result.Stderr
for _, needle := range []string{
"warning:",
"does not split a block into multiple paragraphs",
"combined bold+italic markers",
} {
if strings.Contains(combined, needle) {
t.Errorf("dry-run output must not surface pre-write warning %q\nstdout:\n%s\nstderr:\n%s",
needle, result.Stdout, result.Stderr)
}
}
}

View File

@@ -43,9 +43,9 @@ func TestDocs_UpdateWorkflow(t *testing.T) {
Args: []string{
"docs", "+update",
"--doc", docToken,
"--command", "overwrite",
"--doc-format", "markdown",
"--content", "# " + updatedTitle + "\n\n" + updatedContent,
"--mode", "overwrite",
"--markdown", updatedContent,
"--new-title", updatedTitle,
},
DefaultAs: defaultAs,
})
@@ -61,15 +61,12 @@ func TestDocs_UpdateWorkflow(t *testing.T) {
Args: []string{
"docs", "+fetch",
"--doc", docToken,
"--doc-format", "markdown",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
content := gjson.Get(result.Stdout, "data.document.content").String()
assert.Contains(t, content, updatedTitle)
assert.Contains(t, content, "This is the updated content.")
assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String())
})
}

View File

@@ -21,9 +21,9 @@ func createDocWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, f
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--parent-token", folderToken,
"--doc-format", "markdown",
"--content", "# " + title + "\n\n" + markdown,
"--folder-token", folderToken,
"--title", title,
"--markdown", markdown,
},
DefaultAs: defaultAs,
})
@@ -31,7 +31,7 @@ func createDocWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, f
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.document.document_id").String()
docToken := gjson.Get(result.Stdout, "data.doc_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
parentT.Cleanup(func() {