Compare commits

...

5 Commits

Author SHA1 Message Date
jiaxing.04
963d8b3c24 feat(drive): add +folder-permission-get shortcut
Add a Drive shortcut for reading a folder's own public permission settings through the v2 permission endpoint. This gives agents a typed, folder-specific path when raw permission.public get does not accept folder targets, without turning folder permission checks into recursive governance scans.

Key features:

- Accept exactly one folder locator through --url or --folder-token and validate non-folder inputs before API calls

- Return permission_public unchanged so callers can reason from server-provided fields

- Register the shortcut and cover unit plus dry-run E2E behavior

- Document when to use +folder-permission-get in lark-drive permission workflows
2026-07-03 16:58:43 +08:00
caojie0621
a1506cdffb feat: add docs history shortcuts (#1612)
Add docs +history-list, +history-revert, and +history-revert-status backed by docs_ai history OpenAPI endpoints.

Document the safe history workflow and extend dry-run/live E2E coverage for the new shortcuts.
2026-07-03 16:21:18 +08:00
liuxin-0319
3595356ea1 chore: sync lark-doc skill from online-doc (#1701) 2026-07-03 15:55:46 +08:00
zhangjun-bytedance
73be1d06ec bugfix 0702 about speaker replace (#1731) 2026-07-03 14:05:48 +08:00
liujinkun2025
cccf025599 docs(drive): document 30-char query limit for +search (#1560)
The Search v2 API rejects queries longer than 30 characters (counted by
Unicode code point, CJK 1 each) with 99992402 field validation failed —
it is a hard error, not truncation. Surface this in the --query -h help
text and the lark-drive search skill so callers compress long queries
before searching instead of hitting the error.

Change-Id: Ieb30a66edae7a573690c49719627ec8fb2500a1a
2026-07-03 11:39:07 +08:00
34 changed files with 1712 additions and 451 deletions

3
.gitignore vendored
View File

@@ -27,6 +27,9 @@ Thumbs.db
# Go
docs/ref
docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/

View File

@@ -20,13 +20,28 @@ import (
"github.com/spf13/cobra"
)
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
flag := cmd.Flags().Lookup("as")
if flag == nil {
t.Fatal("expected --as flag to be registered")
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute()
if err == nil {
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream",
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err == nil {
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
},
})
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(NewCmdApi(f, nil))
root := newTestRootCmd()
root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute()
if err == nil {
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute()
if err != nil {
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute()
if err != nil {
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts)
})
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute()
if err != nil {
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
},
})
cmd := NewCmdApi(f, nil)
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute()
if err == nil {
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update":
return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default:
return docsSkillReadCommand
}
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help"
case "update":
return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default:
return "lark-cli docs --help"
}
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
DocsCreate,
DocsFetch,
DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert,
DocMediaUpload,
DocMediaPreview,

View File

@@ -0,0 +1,153 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type driveFolderPermissionGetSpec struct {
FolderToken string
}
func readDriveFolderPermissionGetSpec(runtime *common.RuntimeContext) (driveFolderPermissionGetSpec, error) {
rawURL := strings.TrimSpace(runtime.Str("url"))
rawToken := strings.TrimSpace(runtime.Str("folder-token"))
if rawURL == "" && rawToken == "" {
return driveFolderPermissionGetSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"pass exactly one of --url or --folder-token",
).WithParam("--url")
}
if rawURL != "" && rawToken != "" {
return driveFolderPermissionGetSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--url and --folder-token are mutually exclusive; pass only one folder locator",
).WithParam("--url")
}
if rawToken != "" {
if err := validate.ResourceName(rawToken, "--folder-token"); err != nil {
return driveFolderPermissionGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
}
return driveFolderPermissionGetSpec{FolderToken: rawToken}, nil
}
ref, ok := common.ParseResourceURL(rawURL)
if !ok {
return driveFolderPermissionGetSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported --url %q: pass a recognized Lark Drive folder URL such as https://example.feishu.cn/drive/folder/<folder_token>",
rawURL,
).WithParam("--url")
}
if ref.Type != "folder" {
return driveFolderPermissionGetSpec{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--url must point to a Drive folder; got resource type %q",
ref.Type,
).WithParam("--url")
}
if err := validate.ResourceName(ref.Token, "--url"); err != nil {
return driveFolderPermissionGetSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--url")
}
return driveFolderPermissionGetSpec{FolderToken: ref.Token}, nil
}
func (s driveFolderPermissionGetSpec) url(runtime *common.RuntimeContext) string {
if runtime != nil && runtime.Config != nil {
if u := common.BuildResourceURL(runtime.Config.Brand, "folder", s.FolderToken); u != "" {
return u
}
}
return common.BuildResourceURL("", "folder", s.FolderToken)
}
func (s driveFolderPermissionGetSpec) params() map[string]interface{} {
return map[string]interface{}{"type": "folder"}
}
func (s driveFolderPermissionGetSpec) apiPath() string {
return drivePermissionPublicV2Path(s.FolderToken)
}
func drivePermissionPublicV2Path(token string) string {
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
}
func (s driveFolderPermissionGetSpec) output(runtime *common.RuntimeContext, data map[string]interface{}) map[string]interface{} {
permissionPublic := interface{}(data)
if nestedPermissionPublic := common.GetMap(data, "permission_public"); nestedPermissionPublic != nil {
permissionPublic = nestedPermissionPublic
}
return map[string]interface{}{
"permission_public": permissionPublic,
}
}
// DriveFolderPermissionGet queries the permission_public settings for a Drive
// folder itself.
var DriveFolderPermissionGet = common.Shortcut{
Service: "drive",
Command: "+folder-permission-get",
Description: "Get a Drive folder's sharing, copy, download, and comment permission settings",
Risk: "read",
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "url", Desc: "Drive folder URL, for example https://example.feishu.cn/drive/folder/<folder_token>"},
{Name: "folder-token", Desc: "Drive folder token; mutually exclusive with --url"},
},
Tips: []string{
"Pass exactly one of --url or --folder-token.",
"This shortcut reads the folder's own permission settings; it does not list child document permissions.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := readDriveFolderPermissionGetSpec(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveFolderPermissionGetSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
Desc("Get Drive folder permission settings").
GET(spec.apiPath()).
Params(spec.params())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveFolderPermissionGetSpec(runtime)
if err != nil {
return err
}
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for folder %s...\n", common.MaskToken(spec.FolderToken))
data, err := runtime.CallAPITyped(
"GET",
spec.apiPath(),
spec.params(),
nil,
)
if err != nil {
return err
}
out := spec.output(runtime, data)
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "Type: folder\n")
fmt.Fprintf(w, "FolderToken: %s\n", spec.FolderToken)
fmt.Fprintf(w, "URL: %s\n", spec.url(runtime))
})
return nil
},
}

View File

@@ -0,0 +1,221 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"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"
)
func newDriveFolderPermissionGetRuntime(t *testing.T, rawURL, folderToken string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "drive +folder-permission-get"}
cmd.Flags().String("url", "", "")
cmd.Flags().String("folder-token", "", "")
if rawURL != "" {
if err := cmd.Flags().Set("url", rawURL); err != nil {
t.Fatalf("set --url: %v", err)
}
}
if folderToken != "" {
if err := cmd.Flags().Set("folder-token", folderToken); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
}
return common.TestNewRuntimeContext(cmd, driveTestConfig())
}
func TestDriveFolderPermissionGetSpecResolvesFolderURL(t *testing.T) {
t.Parallel()
runtime := newDriveFolderPermissionGetRuntime(t, "https://example.feishu.cn/drive/folder/fldTok?from=share", "")
spec, err := readDriveFolderPermissionGetSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.FolderToken != "fldTok" {
t.Fatalf("FolderToken = %q, want fldTok", spec.FolderToken)
}
}
func TestDriveFolderPermissionGetSpecResolvesBareFolderToken(t *testing.T) {
t.Parallel()
runtime := newDriveFolderPermissionGetRuntime(t, "", " fldTok ")
spec, err := readDriveFolderPermissionGetSpec(runtime)
if err != nil {
t.Fatalf("read spec: %v", err)
}
if spec.FolderToken != "fldTok" {
t.Fatalf("FolderToken = %q, want fldTok", spec.FolderToken)
}
}
func TestDriveFolderPermissionGetSpecValidationErrorsAreTyped(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURL string
folderToken string
wantParam string
wantMessage string
}{
{
name: "missing locator",
wantParam: "--url",
wantMessage: "pass exactly one",
},
{
name: "mutually exclusive locators",
rawURL: "https://example.feishu.cn/drive/folder/fldTok",
folderToken: "fldTok",
wantParam: "--url",
wantMessage: "mutually exclusive",
},
{
name: "non-folder URL",
rawURL: "https://example.feishu.cn/docx/doxTok",
wantParam: "--url",
wantMessage: "must point to a Drive folder",
},
{
name: "unsupported URL",
rawURL: "https://example.feishu.cn/calendar/calTok",
wantParam: "--url",
wantMessage: "unsupported --url",
},
{
name: "invalid bare folder token",
folderToken: "../bad",
wantParam: "--folder-token",
wantMessage: "--folder-token",
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
runtime := newDriveFolderPermissionGetRuntime(t, tt.rawURL, tt.folderToken)
_, err := readDriveFolderPermissionGetSpec(runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr, ok := err.(*errs.ValidationError); ok {
if validationErr.Param != tt.wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
}
} else {
t.Fatalf("error type = %T, want *errs.ValidationError", err)
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
}
})
}
}
func TestDriveFolderPermissionGetDryRunIncludesGETRequest(t *testing.T) {
t.Parallel()
runtime := newDriveFolderPermissionGetRuntime(t, "https://example.feishu.cn/drive/folder/fldTok", "")
dry := DriveFolderPermissionGet.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
out := string(data)
for _, want := range []string{
`"/open-apis/drive/v2/permissions/fldTok/public"`,
`"GET"`,
`"type":"folder"`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, `"folder_token"`) {
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
}
}
func TestDriveFolderPermissionGetExecutePreservesPermissionPublic(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v2/permissions/fldTok/public?type=folder",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"permission_public": map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_folder_field": "preserved",
},
},
},
})
err := mountAndRunDrive(t, DriveFolderPermissionGet, []string{
"+folder-permission-get",
"--folder-token", "fldTok",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
for _, key := range []string{"type", "folder_token", "url"} {
if _, ok := data[key]; ok {
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
}
}
permissionPublic, _ := data["permission_public"].(map[string]interface{})
if permissionPublic == nil {
t.Fatalf("permission_public missing in output: %#v", data)
}
for key, want := range map[string]interface{}{
"link_share_entity": "closed",
"external_access_entity": "closed",
"security_entity": "anyone_can_view",
"comment_entity": "anyone_can_view",
"share_entity": "anyone",
"manage_collaborator_entity": "collaborator_can_view",
"lock_switch": false,
"server_future_folder_field": "preserved",
} {
if permissionPublic[key] != want {
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
}
}
}

View File

@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

@@ -31,6 +31,7 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveFolderPermissionGet,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

@@ -37,6 +37,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+task_result",
"+apply-permission",
"+member-add",
"+folder-permission-get",
"+secure-label-list",
"+secure-label-update",
"+search",

View File

@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
dr := common.NewDryRunAPI()
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
return common.NewDryRunAPI().
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
_, err := runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
}
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil
},
}
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body
}
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{
"minute_token": minuteToken,
"to_user_id": toUserID,
}
if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else {
out["from_user_id"] = fromUserID
}
return out
}
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
if fromSpeakerID != "" {
return fromSpeakerID
}

View File

@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
}
}
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
// straight through without a second speakerlist call. If the code still
// prefetched speakerlist, the unregistered GET would fail the request.
reg.Register(&httpmock.Stub{
Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user",
}, f, stdout)
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
var envelope struct {
Data struct {
MinuteToken string `json:"minute_token"`
FromSpeakerInput string `json:"from_speaker_input"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
}
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
}
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
}
}
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))

View File

@@ -5,7 +5,7 @@ description: "飞书云文档Docx / Wiki 文档):读取和编辑飞书文
metadata:
requires:
bins: ["lark-cli"]
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
---
# docs
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
**CRITICAL — 执行对应操作前MUST 先用 Read 工具读取以下文件,缺一不可:**
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
> **格式选择规则(全局):**
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML(可用 callout、grid、checkbox 等富 block
> - **创建 / 导入场景**`docs +create`,或 `docs +update --command append/overwrite` 的整段写入XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown否则默认 XML。
> - **精准编辑场景**`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML`--doc-format xml`即默认值。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
## 快速决策
@@ -39,9 +39,10 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID插入 / 复制后要重新 fetch 才能拿到新 block ID
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
- 新增画板必须隔离到 SubAgent简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型Mermaid 可由主 Agent 直接插入SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`
@@ -69,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |

View File

@@ -2,14 +2,14 @@
> **前置条件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、并行执行策略
> 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、单 Agent 串行撰写
>
> **未读完以上文件就生成内容会导致格式错误。**
从 XML默认或 Markdown 内容创建一个新的飞书云文档。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown没有明确指示时默认 XML表达能力更强可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown也不要在用户已给出 Markdown 时强行改成 XML。
## 命令
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
## 参考
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流Code-Act Loop、单 Agent 串行撰写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档

View File

@@ -0,0 +1,107 @@
# docs history历史版本与回滚
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
## 安全流程
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id``revision_id``edit_time``name/description`)并确认后回滚。
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`
5.`+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
## 按 revision_id 或时间点回滚
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
4. 从最终匹配条目读取 `history_version_id``history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`
候选确认时使用类似格式:
```text
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
```
## 命令
```bash
# 列出历史版本
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
# 翻页
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
# 回滚到指定 history_version_id默认等待 30000ms
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
# 只发起任务,不等待
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
# 查询回滚任务状态
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
```
## 参数
| 命令 | 参数 | 必填 | 说明 |
|-|-|-|-|
| `+history-list` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
| `+history-revert` | `--doc` | 是 | Docx URL/token或可解析为 Docx 的 wiki URL |
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
## 返回值要点
`+history-list` 返回:
```json
{
"entries": [
{
"revision_id": 42,
"history_version_id": "11",
"edit_time": "1780000000",
"type": 1,
"name": "版本名",
"description": "版本说明",
"editor_ids": ["ou_xxx"]
}
],
"has_more": true,
"page_token": "page_token"
}
```
`+history-revert` 返回:
```json
{
"task_id": "task_xxx",
"status": "running",
"history_version_id": "11",
"poll_after_ms": 10000
}
```
`+history-revert-status` 返回:
```json
{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": ["blk_xxx"]
}
```
`status` 可能是 `running``done``partial_failed``failed`。当状态是 `partial_failed``failed` 时,优先检查 `failed_block_tokens`

View File

@@ -3,8 +3,8 @@
> **前置条件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、并行执行策略
> 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、单 Agent 串行改写
>
> **未读完以上文件就生成内容会导致格式错误。**
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)
## 最佳实践
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
## 参考
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、并行执行策略
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流Code-Act Loop、单 Agent 串行改写
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档

View File

@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
`$URL` 可以是用户给出的 docx/wiki URL也可以是可被 `docs +fetch` 解析的 token。
## 统计范围
先判断用户要求的是**整篇文档**还是**局部内容**
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
```bash
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
如果 `unknown_blocks``unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
## 字数遵循校验
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x``[x+1, +∞)``<y``(-∞, y-1]``x-y``[x, y]``x 字左右``[round(0.9x), round(1.1x)]`
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
## 输出示例
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:

View File

@@ -7,7 +7,7 @@
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
2. **Execute执行**由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent见步骤三
3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
@@ -16,44 +16,31 @@
## 典型 Code-Act Loop 流程
### 步骤一:规划与初始创建(串行)
### 步骤一:规划与撰写(单 Agent 串行)
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
1. 分析用户需求:受众、目的、范围
2. 设计大纲根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式不要默认套固定章节、固定开头或固定富 block 配比
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
- 完整内容留到步骤二,由各 Agent `block_insert_after --block-id <章节标题 block_id>` 分段写入。
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
3. `docs +create` 创建并撰写:
- **短文档**一次写入完整内容
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**`block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
- ⚠️ **`@file` 路径限制**`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
### 步骤二:分段撰写(并行 Agent
### 步骤二:整合审查与画板识别(串行
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
- `lark-doc-xml.md``lark-doc-style.md` 的完整路径Agent 须先读取)
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则。默认用文字只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
### 步骤三:整合审查与画板识别(串行)
### 步骤三:画板处理与润色
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
8. 由**主 Agent 自行润色**(不另起内容子 Agent正文始终一人维护文字密集且不易读时优先拆段、加小标题或调整顺序——叙述内容保持成段**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
### 步骤四:画板处理与润色(并行 Agent
### 步骤四:专项校验(按需执行
8. **优先处理步骤三识别出的画板需求**
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
9. Spawn 内容改写 Agent 定向润色:
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
- 本地图片使用 `docs +media-insert` 插入
## Agent 子任务要求
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
已有画板更新 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现结果

View File

@@ -1,86 +1,68 @@
# 文档表达组件参考
# 飞书文档写作原则
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。
## 一、用户明确要求优先
## 一、核心原则
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
## 二、默认写连贯段落
## 二、元素选择指南
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
| 内容 | 用什么 | ❌ 别 |
|---|---|---|
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
| 纯短并列项(无描述,如材料清单) | 列表 | — |
| 章节(内容成块、需在目录导航) | 标题层级 | — |
| 场景 | 可选表达方式 |
|--------------------------------------------|---------------------------------------|
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
| 简短低风险对比 | `<grid>` 2 列分栏 |
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
| 任务清单 / 检查项 | `<checkbox>` |
| 代码片段 | `<pre lang="x" caption="说明">` |
| 引用 / 公式 | `<blockquote>` / `<latex>` |
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
- 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
- **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
## 三、按体裁写
### 画板意图识别
- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block高亮块、分栏等粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
- **一般文档**:以可读为先,不堆砌结构。
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
## 四、编号与层级
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
- 公文 / 正式材料常用:「一、→(一)→ 1.→1中文大层级 + 阿拉伯细分层级)。
- 学术 / 技术 / 商业报告「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
- ⚠️ **「一、」只能配「要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号****不跳级**。
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
## 五、飞书特有组件,克制使用
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项全文极少0~1 个),不要每节导语 / 结论都做成高亮块。
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
| 语义 | 背景色 | 文字色 |
|-|-|-|
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
| 信息、说明 | `light-blue` | `blue` |
| 成功、推荐 | `light-green` | `green` |
| 警告 / 错误 / 风险 | `light-red` | `red` |
| 注意、待确认 | `light-yellow` | `yellow` |
| 中性、辅助 | `light-gray` | — |
**判断规则:**
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
## 六、写完自检
## 三、颜色语义
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格:
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|-|-|-|-|
| 信息、说明 | "说明:" | `light-blue` | `blue` |
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
| 中性、辅助 | — | `light-gray` | — |
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
## 四、排版规范
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
## 五、质量自检
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
| 指标 | 自检问题 |
|-|-|
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |
交付前快速回看:
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
- **标题是否滥用**"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用如「一、」配「1.1」)。
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。

View File

@@ -5,7 +5,7 @@
## 核心方法论 — Code-Act Loop
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
1. **Plan规划** — 根据用户目标和文档当前状态,评估下一步该做什么
2. **Execute执行** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
2. **Execute执行**由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent见步骤二
3. **Observe观察** — 检查命令输出,验证正确性,确认内容是否满足用户目标
4. **Iterate迭代** — 如需调整,回到 Plan 继续循环
@@ -23,33 +23,26 @@
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
3. **画板意图识别**:逐章节扫描,`lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节block ID、推荐画板类型、mermaid/SVG路径和源内容片段
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节block ID、推荐画板类型、mermaid/SVG路径和源内容片段
4. 向用户简要说明改进计划(包含识别出的画板机会)
### 步骤二:定向改写(并行 Agent
### 步骤二:定向改写( Agent 串行
5. **优先处理步骤一识别出的画板候选段落**
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID
5. **优先处理步骤一识别出的画板候选段落**读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
### 步骤三:验证(串行)
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
## Agent 子任务要求
### 步骤四:专项校验(按需执行)
内容改写 Agent 必须收到:文档 token、章节范围标题/block ID`lark-doc-xml.md``lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验向用户呈现结果
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
SVG SubAgent 必须收到:文档 token、插入位置标题/block ID、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`
已有画板更新 SubAgent 必须收到board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
**上下文节省提示**Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询文件夹权限设置、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -22,6 +22,7 @@ metadata:
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**查询 Drive 文件夹自身的公开访问、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +folder-permission-get`;它只读取文件夹自身设置,不递归审计子文档权限。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
@@ -106,6 +107,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
### 权限能力入口
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch``91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
- 用户要查询 Drive 文件夹自身的公开访问和协作权限设置,使用 [`+folder-permission-get`](references/lark-drive-folder-permission-get.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
@@ -148,6 +150,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical tokenwiki URL 会自动解包到底层文档。 |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create真实写入需要 `--yes`。 |
| [`+folder-permission-get`](references/lark-drive-folder-permission-get.md) | 查询 Drive 文件夹自身的公开访问、协作者管理、安全与评论权限设置;支持 `--url``--folder-token`;不递归读取子文档权限。 |
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |

View File

@@ -0,0 +1,80 @@
# drive +folder-permission-get查询文件夹权限设置
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、身份选择、全局参数和权限错误处理。
本 skill 对应 shortcut`lark-cli drive +folder-permission-get`。它直接读取 Drive 文件夹自身的公开访问和协作权限设置。
## 适用场景
- 用户明确要查看“文件夹权限设置”“文件夹分享设置”“文件夹公开访问 / 协作者管理 / 安全 / 评论权限”。
- 输入是 `/drive/folder/<folder_token>` URL或已经拿到裸 `folder_token`
- 只需要读取当前文件夹自身设置,不需要递归扫描子文件、子文件夹或文档权限。
如果用户要做文件夹下所有文档的权限风险报告、批量整改、owner 转移或密级标签治理,进入 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md)。
## 命令
```bash
# 通过文件夹 URL 查询
lark-cli drive +folder-permission-get \
--url "https://example.feishu.cn/drive/folder/fldcnxxxxxxxxx" \
--as user --format json
# 通过裸 folder token 查询
lark-cli drive +folder-permission-get \
--folder-token "fldcnxxxxxxxxx" \
--as bot --format json
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 二选一 | Drive 文件夹 URL必须是 `/drive/folder/<folder_token>` 路径。非 folder URL 会被拒绝。 |
| `--folder-token` | 二选一 | 裸 folder token。适合已经从 `drive +inspect``drive files list` 或其他流程中拿到 token 的场景。 |
`--url``--folder-token` 必须且只能传一个。不要把文档、Wiki、Sheet、Base 或普通文件 URL 传给本 shortcut。
身份与输出格式沿用全局参数约定:按需使用 `--as user|bot`;自动化解析时使用 `--format json`。权限取决于当前身份是否能访问该文件夹,以及应用 / 用户授权是否满足 API 要求。
## 输出
成功时 `data` 只返回 `permission_public`,并完整透传服务端当前返回的公共访问和协作权限设置:
```json
{
"ok": true,
"identity": "user",
"data": {
"permission_public": {
"comment_entity": "anyone_can_edit",
"external_access_entity": "open",
"link_share_entity": "anyone_readable",
"lock_switch": false,
"manage_collaborator_entity": "collaborator_can_edit",
"security_entity": "only_full_access",
"share_entity": "same_tenant"
}
}
}
```
`permission_public` 是服务端当前返回的完整权限设置对象。字段可能随 OpenAPI 演进增加或缺失只根据实际返回字段做判断不要臆造未返回的权限状态。JSON `data` 不包含 `type``folder_token``url`;如需定位目标,复用调用命令中的 `--url` / `--folder-token` 输入。
`--dry-run` 输出只展示待请求的 API、method 和 params不额外输出顶层 `folder_token`
## 边界
- 只读操作,不修改权限,不需要 `--yes`
- 只查询文件夹自身设置,不递归读取子文件夹或子文档权限。
- 不返回协作者列表、继承链、历史权限变更、访问记录、DLP 或 AI 索引状态。
- 本 shortcut 是 folder-only其他文档类型继续使用 `drive permission.public get` 或权限治理 workflow。
- 当前 raw command schema 未把 `folder` 纳入 `drive permission.public get --type`,不要用 raw command 猜 `type=folder`;文件夹读取走本 shortcut 的 v2 endpoint。
## 常见错误
| 症状 | 原因 | 处理 |
|------|------|------|
| `--url and --folder-token are mutually exclusive` | 同时传了两种输入 | 只保留一个输入。 |
| `--url or --folder-token is required` | 没传目标文件夹 | 传 `/drive/folder/<token>` URL 或裸 `folder_token`。 |
| `--url must be a Drive folder URL` | URL 不是 `/drive/folder/<token>` | 先确认资源类型;文档 / Wiki / Sheet 不走本 shortcut。 |
| Permission denied / missing scope | 当前身份无文件夹访问权或缺授权 | 按 [`lark-shared`](../../lark-shared/SKILL.md) 处理。bot 不能访问用户私有文件夹时,改用 `--as user` 或先授权 bot。 |

View File

@@ -23,6 +23,8 @@
> 错误:`lark-cli drive +search 方案`
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
>
> **`--query` 最长 30 个字符**按字符数Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
### 自然语言 → 命令映射速查
@@ -101,7 +103,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
| 参数 | 必填 | 说明 |
|---|---|---|
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:``""``OR``-`)。空字符串或省略表示纯 filter 浏览 |
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:``""``OR``-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
| `--page-size <n>` | 否 | 每页数量,默认 15最大 20。超过 20 自动 clamp非正数≤0回落 15**非数字值直接返回 validation 错误** |
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
| `--format` | 否 | `json`(默认)/ `pretty` |

View File

@@ -69,6 +69,14 @@ lark-cli drive permission.public get \
--as user --format json
```
读取 Drive 文件夹自身 public permission
```bash
lark-cli drive +folder-permission-get \
--folder-token "<folder_token>" \
--as user --format json
```
按需读取访问统计:
```bash

View File

@@ -119,9 +119,9 @@ Risk / Structure: `R2` / `S2`
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers``unsupported_checks`
2. 发现阶段必须生成稳定 `path`。不要只保存 title同名文档必须能通过 path 或 token 区分。
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides`;未来新增类型以运行时 schema 为准。
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc``sheet``file``wiki``bitable``docx``mindnote``minutes``slides`;未来新增类型以运行时 schema 为准。Drive 文件夹自身权限查询走 `drive +folder-permission-get`
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`URL、owner、密级等 metadata 可能进入 `unsupported_checks`
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
5. `folder` 只作为递归容器,不执行 raw `permission.public get` / `patch`;如用户明确要查询文件夹自身公开访问和协作权限设置,可对该文件夹单独执行 `drive +folder-permission-get`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut``catalog` 或缺少 stable token/type 的条目必须记录为 unsupported除非后续 API 明确解析出支持目标。
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker不要默认展示内部 page token / cursor。
Wiki space / node 发现:
@@ -133,7 +133,7 @@ Wiki space / node 发现:
Drive folder 发现:
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`文件夹自身公开权限不支持;继续枚举其子文档
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +folder-permission-get` 读取该文件夹自身设置
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files``has_more``next_page_token`。不要把第一页数量当作完整范围。
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
@@ -141,7 +141,7 @@ Drive folder 发现:
## Fact Read Rules
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets``discovered_targets` 超过 200 个时,必须分批读取并合并结果。
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks``partial`,不要阻断其他目标。
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。文件夹自身权限读取使用 `drive +folder-permission-get`单个目标失败时记录 `unsupported_checks``partial`,不要阻断其他目标。
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑差异只体现在目标来源、coverage summary 和输出聚合。
@@ -170,7 +170,7 @@ Drive folder 发现:
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
- `drive permission.public get` 只用于 `drive +inspect``DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
- `drive permission.public get` 只用于 `drive +inspect``DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。文件夹自身设置用 `drive +folder-permission-get`
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。

View File

@@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace``--from-speaker-id` 只接受 `speaker_id`。
`+speaker-replace` **不会**自己请求 speakerlist`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found
### 2. 新说话人必须是 open_id
`--to-user-id` 仅支持 `ou_` 开头的 open_id**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。

View File

@@ -1,9 +1,9 @@
# Docs CLI E2E Coverage
## Metrics
- Denominator: 8 leaf commands
- Covered: 3
- Coverage: 37.5%
- Denominator: 11 leaf commands
- Covered: 6
- Coverage: 54.5%
## Summary
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
@@ -11,6 +11,8 @@
- 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; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
- 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.
@@ -20,6 +22,9 @@
| --- | --- | --- | --- | --- | --- |
| ✓ | 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; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
| ✓ | 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`; default `extra_param.enable_user_cite_reference_map=true` | |
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
| ✕ | 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 |

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package docs
import (
"context"
"os"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDocs_HistoryWorkflow(t *testing.T) {
if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" {
t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow")
}
clie2e.SkipWithoutUserToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
folderName := "lark-cli-e2e-docs-history-folder-" + suffix
docTitle := "lark-cli-e2e-docs-history-" + suffix
originalMarker := "original history marker " + suffix
updatedMarker := "updated history marker " + suffix
const defaultAs = "user"
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs)
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+update",
"--doc", docToken,
"--command", "overwrite",
"--doc-format", "markdown",
"--content", "# " + docTitle + "\n\n" + updatedMarker,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
updateResult.AssertStdoutStatus(t, true)
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
fetchUpdated.AssertExitCode(t, 0)
fetchUpdated.AssertStdoutStatus(t, true)
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String()
assert.Contains(t, updatedContent, updatedMarker)
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int()
require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout)
var revertHistoryVersionID string
require.Eventually(t, func() bool {
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-list",
"--doc", docToken,
"--page-size", "20",
},
DefaultAs: defaultAs,
})
require.NoError(t, listErr)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
revisionID := entry.Get("revision_id").Int()
historyVersionID := entry.Get("history_version_id").String()
if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" {
revertHistoryVersionID = historyVersionID
return true
}
}
return false
}, 45*time.Second, 3*time.Second, "history list did not expose a prior revision")
require.NotEmpty(t, revertHistoryVersionID)
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-revert",
"--doc", docToken,
"--history-version-id", revertHistoryVersionID,
"--wait-timeout-ms", "30000",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
revertResult.AssertExitCode(t, 0)
revertResult.AssertStdoutStatus(t, true)
status := gjson.Get(revertResult.Stdout, "data.status").String()
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
statusStdout := revertResult.Stdout
if status == "running" {
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
require.Eventually(t, func() bool {
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+history-revert-status",
"--doc", docToken,
"--task-id", taskID,
},
DefaultAs: defaultAs,
})
require.NoError(t, statusErr)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
statusStdout = statusResult.Stdout
status = gjson.Get(statusResult.Stdout, "data.status").String()
return status != "" && status != "running"
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
}
require.Equal(t, "done", status, "status stdout:\n%s", statusStdout)
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
DefaultAs: defaultAs,
})
require.NoError(t, err)
fetchReverted.AssertExitCode(t, 0)
fetchReverted.AssertStdoutStatus(t, true)
revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String()
assert.Contains(t, revertedContent, originalMarker)
assert.NotContains(t, revertedContent, updatedMarker)
}

View File

@@ -26,7 +26,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
tests := []struct {
name string
args []string
wantContains []string
wantURL string
wantParams map[string]any
wantBody map[string]any
wantExtraParam string
wantRefLabel string
}{
@@ -37,7 +40,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents",
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
},
{
name: "create api-version v1 compatibility",
@@ -47,7 +50,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--content", "<title>Dry Run</title><p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents",
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
},
{
name: "fetch",
@@ -56,7 +59,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--doc", "doxcnDryRunE2E",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"},
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
},
{
@@ -68,7 +71,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--content", "<p>hello</p>",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
},
{
name: "update reference-map",
@@ -80,7 +83,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
wantRefLabel: "widget-ref-value",
},
{
@@ -92,7 +95,50 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
"--block-id", "blkA,blkB,blkC",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
},
{
name: "history list",
args: []string{
"docs", "+history-list",
"--doc", "doxcnDryRunE2E",
"--page-size", "5",
"--page-token", "page_token_1",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories",
wantParams: map[string]any{
"page_size": 5,
"page_token": "page_token_1",
},
},
{
name: "history revert",
args: []string{
"docs", "+history-revert",
"--doc", "doxcnDryRunE2E",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert",
wantBody: map[string]any{
"history_version_id": "42",
"wait_timeout_ms": 0,
},
},
{
name: "history revert status",
args: []string{
"docs", "+history-revert-status",
"--doc", "doxcnDryRunE2E",
"--task-id", "task_1",
"--dry-run",
},
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status",
wantParams: map[string]any{
"task_id": "task_1",
},
},
}
@@ -106,10 +152,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
result.AssertExitCode(t, 0)
combined := result.Stdout + "\n" + result.Stderr
for _, want := range []string{
tt.wantURL,
"docs_ai/v1",
} {
for _, want := range append(tt.wantContains, "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)
}
@@ -120,6 +163,15 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
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)
}
if tt.wantURL != "" {
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
}
for key, want := range tt.wantParams {
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
}
for key, want := range tt.wantBody {
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
}
if tt.wantExtraParam != "" {
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
@@ -132,6 +184,21 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
}
}
func assertDryRunField(t *testing.T, stdout, path string, want any) {
t.Helper()
got := gjson.Get(stdout, path)
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
switch want := want.(type) {
case int:
require.Equal(t, int64(want), got.Int(), "%s in stdout:\n%s", path, stdout)
case string:
require.Equal(t, want, got.String(), "%s in stdout:\n%s", path, stdout)
default:
t.Fatalf("unsupported dry-run assertion type %T for %s", want, path)
}
}
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
// Fake creds are enough — dry-run short-circuits before any real API call.
t.Setenv("LARKSUITE_CLI_APP_ID", "app")

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_FolderPermissionGetDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
args []string
}{
{
name: "folder token",
args: []string{
"drive", "+folder-permission-get",
"--folder-token", "fldE2E001",
"--dry-run",
},
},
{
name: "folder URL",
args: []string{
"drive", "+folder-permission-get",
"--url", "https://example.feishu.cn/drive/folder/fldE2E001?from=share",
"--dry-run",
},
},
}
for _, temp := range tests {
tt := temp
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tt.args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v2/permissions/fldE2E001/public" {
t.Fatalf("url = %q, want v2 folder permission public endpoint\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.type").String(); got != "folder" {
t.Fatalf("params.type = %q, want folder\nstdout:\n%s", got, out)
}
if gjson.Get(out, "folder_token").Exists() {
t.Fatalf("folder_token exists in dry-run output, want omitted\nstdout:\n%s", out)
}
})
}
}

View File

@@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
}
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"minutes", "+speaker-replace",
"--minute-token", "obcnexampleminute",
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output)
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output)
assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output)
}