mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
Compare commits
1 Commits
codex/driv
...
sun/lark-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21c992e8ec |
@@ -103,10 +103,6 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
|
||||
return buildStrictModeIntegrationRootCmdWithSetup(t, f, nil)
|
||||
}
|
||||
|
||||
func buildStrictModeIntegrationRootCmdWithSetup(t *testing.T, f *cmdutil.Factory, setup func(*cobra.Command)) *cobra.Command {
|
||||
t.Helper()
|
||||
rootCmd := &cobra.Command{Use: "lark-cli"}
|
||||
rootCmd.SilenceErrors = true
|
||||
@@ -119,9 +115,6 @@ func buildStrictModeIntegrationRootCmdWithSetup(t *testing.T, f *cmdutil.Factory
|
||||
rootCmd.AddCommand(api.NewCmdApi(f, nil))
|
||||
service.RegisterServiceCommands(rootCmd, f)
|
||||
shortcuts.RegisterShortcuts(rootCmd, f)
|
||||
if setup != nil {
|
||||
setup(rootCmd)
|
||||
}
|
||||
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
@@ -362,16 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
|
||||
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithSetup(t, f, func(rootCmd *cobra.Command) {
|
||||
fixture := &cobra.Command{Use: "strict-fixture"}
|
||||
botOnly := &cobra.Command{Use: "bot-only", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
cmdutil.SetSupportedIdentities(botOnly, []string{"bot"})
|
||||
fixture.AddCommand(botOnly)
|
||||
rootCmd.AddCommand(fixture)
|
||||
})
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"strict-fixture", "bot-only",
|
||||
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -10,22 +10,20 @@ import "github.com/larksuite/cli/errs"
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
|
||||
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
|
||||
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
|
||||
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
|
||||
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
|
||||
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
|
||||
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
|
||||
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
|
||||
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
|
||||
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
|
||||
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
|
||||
@@ -114,35 +114,8 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
|
||||
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
|
||||
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{2200, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
|
||||
}
|
||||
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
|
||||
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
|
||||
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
|
||||
// command-specific recovery guidance at the shortcut layer.
|
||||
var wikiCodeMeta = map[int]CodeMeta{
|
||||
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
|
||||
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
|
||||
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }
|
||||
@@ -184,7 +184,6 @@ var DrivePull = common.Shortcut{
|
||||
|
||||
var downloaded, skipped, failed, deletedLocal int
|
||||
downloadFailed := 0
|
||||
aborted := false
|
||||
items := make([]drivePullItem, 0)
|
||||
|
||||
// Deterministic iteration order for output stability.
|
||||
@@ -195,7 +194,7 @@ var DrivePull = common.Shortcut{
|
||||
sort.Strings(downloadablePaths)
|
||||
|
||||
for _, rel := range downloadablePaths {
|
||||
if aborted {
|
||||
if drivePullHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile := remoteFiles[rel]
|
||||
@@ -233,7 +232,6 @@ var DrivePull = common.Shortcut{
|
||||
failed++
|
||||
downloadFailed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -300,7 +298,7 @@ var DrivePull = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_local": deletedLocal,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePullHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -349,6 +347,15 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func drivePullHasTerminalFailure(items []drivePullItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
|
||||
@@ -35,7 +35,6 @@ type drivePushItem struct {
|
||||
Version string `json:"version,omitempty"`
|
||||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
ErrorClass string `json:"error_class,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
@@ -49,7 +48,6 @@ type driveBatchFailureDecision struct {
|
||||
Subtype string
|
||||
Retryable bool
|
||||
Terminal bool
|
||||
Hint string
|
||||
}
|
||||
|
||||
// DrivePush is a one-way, file-level mirror from a local directory onto a
|
||||
@@ -242,7 +240,6 @@ var DrivePush = common.Shortcut{
|
||||
// locally and now on Drive too), which is the worst-of-both-worlds
|
||||
// outcome the review flagged.
|
||||
uploadFailed := false
|
||||
aborted := false
|
||||
|
||||
// folderCache holds rel_path → folder_token. Seeded from the remote
|
||||
// listing (so we don't recreate folders that already exist) and
|
||||
@@ -269,7 +266,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -288,7 +284,7 @@ var DrivePush = common.Shortcut{
|
||||
|
||||
for _, rel := range localPaths {
|
||||
localFile := localFiles[rel]
|
||||
if uploadFailed && aborted {
|
||||
if uploadFailed && drivePushHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -305,7 +301,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
@@ -337,7 +332,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -356,7 +350,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
@@ -369,7 +362,6 @@ var DrivePush = common.Shortcut{
|
||||
failed++
|
||||
uploadFailed = true
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -415,15 +407,10 @@ var DrivePush = common.Shortcut{
|
||||
continue
|
||||
}
|
||||
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
|
||||
if drivePushIsAlreadyDeleted(err) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
|
||||
continue
|
||||
}
|
||||
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
|
||||
abortDelete = true
|
||||
break
|
||||
@@ -442,7 +429,7 @@ var DrivePush = common.Shortcut{
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"deleted_remote": deletedRemote,
|
||||
"aborted": aborted,
|
||||
"aborted": drivePushHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -580,7 +567,6 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
|
||||
Action: action,
|
||||
SizeBytes: sizeBytes,
|
||||
Error: err.Error(),
|
||||
Hint: decision.Hint,
|
||||
Phase: phase,
|
||||
ErrorClass: decision.Class,
|
||||
Code: decision.Code,
|
||||
@@ -627,10 +613,6 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
decision.Class = "file_size_limit"
|
||||
case problem.Code == 1062009:
|
||||
decision.Class = "upload_size_mismatch"
|
||||
case problem.Code == 1061044:
|
||||
decision.Class = "parent_node_missing"
|
||||
decision.Terminal = true
|
||||
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
|
||||
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
|
||||
decision.Class = "remote_not_found"
|
||||
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
|
||||
@@ -644,9 +626,22 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
|
||||
return decision
|
||||
}
|
||||
|
||||
func drivePushIsAlreadyDeleted(err error) bool {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
return ok && problem.Code == 1061007
|
||||
func drivePushHasTerminalFailure(items []drivePushItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func driveTerminalBatchErrorClass(errorClass string) bool {
|
||||
switch errorClass {
|
||||
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
|
||||
|
||||
@@ -732,65 +732,6 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/drive/v1/files/tok_orphan",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061007,
|
||||
"msg": "file has been delete.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", got)
|
||||
}
|
||||
if got := summary["deleted_remote"]; got != float64(0) {
|
||||
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
||||
t.Fatalf("unexpected already-deleted item: %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
@@ -1196,78 +1137,6 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1061044,
|
||||
"msg": "parent node not exist.",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
||||
}
|
||||
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
||||
if got := summary["failed"]; got != float64(1) {
|
||||
t.Fatalf("summary.failed = %v, want 1", got)
|
||||
}
|
||||
if got := summary["aborted"]; got != true {
|
||||
t.Fatalf("summary.aborted = %v, want true", got)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
||||
}
|
||||
item := items[0]
|
||||
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
||||
t.Fatalf("unexpected failed item: %#v", item)
|
||||
}
|
||||
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
||||
t.Fatalf("unexpected failure metadata: %#v", item)
|
||||
}
|
||||
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
||||
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item["rel_path"] == "b.txt" {
|
||||
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
|
||||
@@ -268,7 +268,6 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// --- Phase 2: Execute sync operations ---
|
||||
var pulled, pushed, skipped, failed int
|
||||
aborted := false
|
||||
items := make([]driveSyncItem, 0)
|
||||
|
||||
// Build push infrastructure: local walk for push + remote views + folder cache.
|
||||
@@ -287,21 +286,16 @@ var DriveSync = common.Shortcut{
|
||||
// Mirror local directory structure first (same as +push), so
|
||||
// empty local directories are not silently dropped.
|
||||
for _, relDir := range localDirs {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
|
||||
continue
|
||||
}
|
||||
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
|
||||
@@ -310,7 +304,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2a. Pull new_remote files.
|
||||
for _, entry := range newRemote {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
targetFile, ok := pullRemoteFiles[entry.RelPath]
|
||||
@@ -324,7 +318,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -336,7 +329,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2b. Push new_local files.
|
||||
for _, entry := range newLocal {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
localFile, ok := pushLocalFiles[entry.RelPath]
|
||||
@@ -348,14 +341,9 @@ var DriveSync = common.Shortcut{
|
||||
parentRel := drivePushParentRel(entry.RelPath)
|
||||
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
|
||||
if ensureErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
|
||||
@@ -364,7 +352,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -376,7 +363,7 @@ var DriveSync = common.Shortcut{
|
||||
|
||||
// 2c. Resolve modified files by --on-conflict strategy.
|
||||
for _, entry := range modified {
|
||||
if aborted {
|
||||
if driveSyncHasTerminalFailure(items) {
|
||||
break
|
||||
}
|
||||
remoteFile := remoteFiles[entry.RelPath]
|
||||
@@ -410,7 +397,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
|
||||
break
|
||||
}
|
||||
@@ -429,14 +415,9 @@ var DriveSync = common.Shortcut{
|
||||
}
|
||||
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
|
||||
if parentErr != nil {
|
||||
item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
|
||||
@@ -454,7 +435,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
|
||||
break
|
||||
}
|
||||
@@ -523,7 +503,6 @@ var DriveSync = common.Shortcut{
|
||||
items = append(items, item)
|
||||
failed++
|
||||
if terminal {
|
||||
aborted = true
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
|
||||
break
|
||||
}
|
||||
@@ -552,7 +531,7 @@ var DriveSync = common.Shortcut{
|
||||
"pushed": pushed,
|
||||
"skipped": skipped,
|
||||
"failed": failed,
|
||||
"aborted": aborted,
|
||||
"aborted": driveSyncHasTerminalFailure(items),
|
||||
},
|
||||
"items": items,
|
||||
}
|
||||
@@ -598,6 +577,15 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
|
||||
return item, decision.Terminal
|
||||
}
|
||||
|
||||
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
|
||||
for _, item := range items {
|
||||
if driveTerminalBatchErrorClass(item.ErrorClass) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// driveSyncAskConflict prompts the user for a conflict resolution strategy
|
||||
// for a single file. Returns the strategy string, or empty string if the
|
||||
// user chose to skip.
|
||||
|
||||
@@ -715,15 +715,9 @@ func markdownUploadProblem(err error, action string) error {
|
||||
case 90003087:
|
||||
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
|
||||
case 1061003, 1061044:
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
|
||||
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.")
|
||||
case 1061004, 1062501:
|
||||
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
|
||||
case 1061101:
|
||||
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
|
||||
case 233523001:
|
||||
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
|
||||
case 99991400:
|
||||
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
|
||||
}
|
||||
}
|
||||
return err
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -31,19 +30,27 @@ var MarkdownCreate = common.Shortcut{
|
||||
Tips: []string{
|
||||
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
|
||||
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
|
||||
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateMarkdownSpec(runtime, spec, true)
|
||||
return validateMarkdownSpec(runtime, markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}, true)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -64,9 +71,14 @@ var MarkdownCreate = common.Shortcut{
|
||||
return dry
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readMarkdownCreateSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
fileSize, err := markdownSourceSize(runtime, spec)
|
||||
if err != nil {
|
||||
@@ -103,139 +115,3 @@ var MarkdownCreate = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
|
||||
spec := markdownUploadSpec{
|
||||
FileName: strings.TrimSpace(runtime.Str("name")),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
|
||||
FilePath: strings.TrimSpace(runtime.Str("file")),
|
||||
FileSet: runtime.Changed("file"),
|
||||
Content: runtime.Str("content"),
|
||||
ContentSet: runtime.Changed("content"),
|
||||
}
|
||||
return normalizeMarkdownCreateTargetSpec(spec)
|
||||
}
|
||||
|
||||
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
|
||||
if spec.FolderToken != "" {
|
||||
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.FolderToken = token
|
||||
}
|
||||
if spec.WikiToken != "" {
|
||||
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
|
||||
if err != nil {
|
||||
return markdownUploadSpec{}, err
|
||||
}
|
||||
spec.WikiToken = token
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownFolderToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
|
||||
WithHint("Pass a Drive folder URL or raw folder token.")
|
||||
}
|
||||
if ref.Type != "folder" {
|
||||
return "", markdownValidationParamError("--folder-token",
|
||||
"--folder-token must identify a Drive folder; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch markdownKnownResourceTokenKind(token) {
|
||||
case "wiki":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
|
||||
WithHint("Pass it with --wiki-token instead.")
|
||||
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
|
||||
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func normalizeMarkdownWikiToken(token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if strings.Contains(token, "://") {
|
||||
ref, ok := common.ParseResourceURL(token)
|
||||
if !ok {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
|
||||
WithHint("Pass a wiki node URL or raw wiki node token.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token",
|
||||
"--wiki-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Token, nil
|
||||
}
|
||||
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
|
||||
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
|
||||
}
|
||||
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func rejectMarkdownPartialToken(token, flagName string) error {
|
||||
if strings.ContainsAny(token, "/?#") {
|
||||
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
|
||||
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMarkdownTargetTokenName(token, flagName string) error {
|
||||
if err := validate.ResourceName(token, flagName); err != nil {
|
||||
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func markdownKnownResourceTokenKind(token string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(token))
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "wik"):
|
||||
return "wiki"
|
||||
case strings.HasPrefix(lower, "docx"):
|
||||
return "docx"
|
||||
case strings.HasPrefix(lower, "doc"):
|
||||
return "doc"
|
||||
case strings.HasPrefix(lower, "sht"):
|
||||
return "sheet"
|
||||
case strings.HasPrefix(lower, "bas"):
|
||||
return "bitable"
|
||||
case strings.HasPrefix(lower, "mn"):
|
||||
return "mindnote"
|
||||
case strings.HasPrefix(lower, "sld"):
|
||||
return "slides"
|
||||
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
|
||||
return "file"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,173 +446,6 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
|
||||
"--dry-run",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"parent_type": "explorer"`) {
|
||||
t.Fatalf("dry-run missing explorer parent_type: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
|
||||
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
|
||||
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected folder-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
|
||||
t.Fatalf("expected folder-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
|
||||
"+create",
|
||||
"--name", "README.md",
|
||||
"--content", "# hello",
|
||||
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected wiki-token URL type error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected wiki-token URL type error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
run func() (string, error)
|
||||
wantMsg string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "wiki token passed as folder token",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
|
||||
wantMsg: "--folder-token looks like a wiki node token",
|
||||
wantHint: "--wiki-token",
|
||||
},
|
||||
{
|
||||
name: "folder token path fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
|
||||
wantMsg: "--folder-token must be a raw token",
|
||||
wantHint: "full Lark URL",
|
||||
},
|
||||
{
|
||||
name: "doc token passed as wiki token",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
|
||||
wantMsg: "--wiki-token must be a wiki node token",
|
||||
wantHint: "",
|
||||
},
|
||||
{
|
||||
name: "wiki token query fragment",
|
||||
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
|
||||
wantMsg: "--wiki-token must be a raw token",
|
||||
wantHint: "path/query/fragment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := tt.run()
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
|
||||
}
|
||||
if folderToken != "folder_token_raw" {
|
||||
t.Fatalf("folder token = %q", folderToken)
|
||||
}
|
||||
|
||||
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
|
||||
}
|
||||
if wikiToken != "wik_placeholder_raw" {
|
||||
t.Fatalf("wiki token = %q", wikiToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
|
||||
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(quotaErr) ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "storage quota is exhausted") {
|
||||
t.Fatalf("quota hint = %q", p.Hint)
|
||||
}
|
||||
|
||||
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
|
||||
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
|
||||
p, ok = errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(serverErr) ok=false")
|
||||
}
|
||||
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
|
||||
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ package wiki
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -27,17 +26,3 @@ func wikiNodeURL(brand core.LarkBrand, node *wikiNodeRecord) string {
|
||||
}
|
||||
return common.BuildResourceURL(brand, "wiki", node.NodeToken)
|
||||
}
|
||||
|
||||
func appendWikiProblemHint(err error, hint string) error {
|
||||
if strings.TrimSpace(hint) == "" {
|
||||
return err
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if strings.TrimSpace(p.Hint) != "" {
|
||||
p.Hint = p.Hint + "\n" + hint
|
||||
} else {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@ package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"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"
|
||||
@@ -132,147 +130,6 @@ func TestWikiNodeListRequiresSpaceID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsNonNumericSpaceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "wikcnABC", "--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected numeric space_id validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--space-id" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--space-id", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "--space-id must be a numeric wiki space_id") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("expected numeric space_id validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsDocumentURLAsParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list",
|
||||
"--space-id", "7211568716812369922",
|
||||
"--parent-node-token", "https://feishu.cn/docx/docxABC",
|
||||
"--as", "user",
|
||||
}, factory, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--parent-node-token" {
|
||||
t.Fatalf("problem = %#v param=%q, want validation/invalid_argument/--parent-node-token", p, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
|
||||
t.Fatalf("expected parent-node-token URL type validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListNormalizesWikiURLParentNodeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("https://feishu.cn/wiki/wikcnPARENT?from=copy")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken() error = %v", err)
|
||||
}
|
||||
if token != "wikcnPARENT" {
|
||||
t.Fatalf("token = %q, want wikcnPARENT", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListRejectsAmbiguousSpaceAndParentTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if err := validateWikiNodeListSpaceID("https://example.invalid/wiki/space"); err == nil {
|
||||
t.Fatalf("expected URL space-id validation error")
|
||||
} else {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, "not a URL or path") || !strings.Contains(p.Hint, "+space-list") {
|
||||
t.Fatalf("problem = %#v, want URL/path message and +space-list hint", p)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "partial wiki path",
|
||||
input: "wik_placeholder/child",
|
||||
wantMsg: "raw wiki node token",
|
||||
},
|
||||
{
|
||||
name: "document token",
|
||||
input: "docx_placeholder_parent",
|
||||
wantMsg: "must be a wiki node token",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := normalizeWikiNodeListParentToken(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parent token validation error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(p.Message, tt.wantMsg) {
|
||||
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListAcceptsEmptyParentToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
token, err := normalizeWikiNodeListParentToken("")
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeWikiNodeListParentToken(empty) error = %v", err)
|
||||
}
|
||||
if token != "" {
|
||||
t.Fatalf("token = %q, want empty", token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListProblemAddsActionableHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidParameters, "param err: invalid page_token").WithCode(131002)
|
||||
got := wikiNodeListProblem(err, nil)
|
||||
p, ok := errs.ProblemOf(got)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf() ok=false")
|
||||
}
|
||||
if !strings.Contains(p.Hint, "page token is invalid or stale") {
|
||||
t.Fatalf("hint = %q, want invalid page token guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
@@ -280,14 +137,14 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_node_1",
|
||||
"obj_token": "docx_1",
|
||||
"obj_type": "docx",
|
||||
@@ -297,7 +154,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
"has_child": true,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_node_2",
|
||||
"obj_token": "docx_2",
|
||||
"obj_type": "docx",
|
||||
@@ -313,7 +170,7 @@ func TestWikiNodeListReturnsNodesForSpace(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -354,14 +211,14 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_child",
|
||||
"obj_token": "docx_child",
|
||||
"obj_type": "docx",
|
||||
@@ -378,7 +235,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -429,7 +286,7 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"space": map[string]interface{}{
|
||||
"space_id": "7211568716812369923",
|
||||
"space_id": "space_personal_42",
|
||||
"name": "My Library",
|
||||
"space_type": "my_library",
|
||||
},
|
||||
@@ -439,14 +296,14 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
// Step 2: list nodes in the resolved space.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369923/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369923",
|
||||
"space_id": "space_personal_42",
|
||||
"node_token": "wik_personal_1",
|
||||
"title": "Personal Note",
|
||||
},
|
||||
@@ -477,8 +334,8 @@ func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %v, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Nodes[0]["space_id"] != "7211568716812369923" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want 7211568716812369923", envelope.Data.Nodes[0]["space_id"])
|
||||
if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" {
|
||||
t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,21 +758,21 @@ func TestWikiNodeListDefaultIsSinglePage(t *testing.T) {
|
||||
// test pins down the "default = single page" contract.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "tok_next",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"space_id": "7211568716812369922", "node_token": "wik_1", "title": "First"},
|
||||
map[string]interface{}{"space_id": "space_123", "node_token": "wik_1", "title": "First"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
@@ -945,14 +802,14 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/7211568716812369922/nodes",
|
||||
URL: "/open-apis/wiki/v2/spaces/space_123/nodes",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"space_id": "7211568716812369922",
|
||||
"space_id": "space_123",
|
||||
"node_token": "wik_1",
|
||||
"obj_type": "docx",
|
||||
"obj_token": "docx_1",
|
||||
@@ -965,7 +822,7 @@ func TestWikiNodeListPrettyFormatRendersFields(t *testing.T) {
|
||||
})
|
||||
|
||||
err := mountAndRunWiki(t, WikiNodeList, []string{
|
||||
"+node-list", "--space-id", "7211568716812369922", "--format", "pretty", "--as", "bot",
|
||||
"+node-list", "--space-id", "space_123", "--format", "pretty", "--as", "bot",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("mountAndRunWiki() error = %v", err)
|
||||
|
||||
@@ -48,19 +48,27 @@ var WikiNodeList = common.Shortcut{
|
||||
"--space-id my_library is a per-user alias and is only valid with --as user.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := readWikiNodeListSpec(runtime); err != nil {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateWikiListPagination(runtime, wikiNodeListMaxPageSize)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
params := map[string]interface{}{"page_size": runtime.Int("page-size")}
|
||||
if spec.ParentNodeToken != "" {
|
||||
params["parent_node_token"] = spec.ParentNodeToken
|
||||
if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" {
|
||||
params["parent_node_token"] = pt
|
||||
}
|
||||
if pt := strings.TrimSpace(runtime.Str("page-token")); pt != "" {
|
||||
params["page_token"] = pt
|
||||
@@ -72,7 +80,7 @@ var WikiNodeList = common.Shortcut{
|
||||
// When the caller passes my_library, +node-list must first resolve it
|
||||
// to the real per-user space_id before listing nodes, mirroring the
|
||||
// two-step orchestration used by +node-create.
|
||||
if spec.SpaceID == wikiMyLibrarySpaceID {
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return d.
|
||||
Desc("2-step orchestration: resolve my_library -> list nodes").
|
||||
GET("/open-apis/wiki/v2/spaces/my_library").
|
||||
@@ -83,17 +91,13 @@ var WikiNodeList = common.Shortcut{
|
||||
Set("space_id", "<resolved_space_id>")
|
||||
}
|
||||
return d.
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spec.SpaceID))).
|
||||
GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))).
|
||||
Params(params).
|
||||
Set("space_id", spec.SpaceID)
|
||||
Set("space_id", spaceID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
warnIfConflictingPagingFlags(runtime)
|
||||
spec, err := readWikiNodeListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaceID := spec.SpaceID
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
|
||||
// Resolve the my_library alias to the per-user real space_id before
|
||||
// listing, so the subsequent request hits a concrete space endpoint.
|
||||
@@ -106,7 +110,7 @@ var WikiNodeList = common.Shortcut{
|
||||
spaceID = resolved
|
||||
}
|
||||
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID, spec.ParentNodeToken)
|
||||
nodes, hasMore, nextToken, err := fetchWikiNodes(runtime, spaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,104 +127,10 @@ var WikiNodeList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
type wikiNodeListSpec struct {
|
||||
SpaceID string
|
||||
ParentNodeToken string
|
||||
}
|
||||
|
||||
func readWikiNodeListSpec(runtime *common.RuntimeContext) (wikiNodeListSpec, error) {
|
||||
spaceID := strings.TrimSpace(runtime.Str("space-id"))
|
||||
// my_library is a per-user personal-library alias; it has no meaning
|
||||
// for a tenant_access_token (--as bot), so reject early with a clear
|
||||
// hint instead of deferring to API-time errors. Matches the contract
|
||||
// used by +node-create and +move.
|
||||
if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID {
|
||||
return wikiNodeListSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "bot identity does not support --space-id my_library; use an explicit numeric --space-id").WithParam("--space-id")
|
||||
}
|
||||
if err := validateWikiNodeListSpaceID(spaceID); err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
|
||||
parentNodeToken, err := normalizeWikiNodeListParentToken(strings.TrimSpace(runtime.Str("parent-node-token")))
|
||||
if err != nil {
|
||||
return wikiNodeListSpec{}, err
|
||||
}
|
||||
return wikiNodeListSpec{SpaceID: spaceID, ParentNodeToken: parentNodeToken}, nil
|
||||
}
|
||||
|
||||
func validateWikiNodeListSpaceID(spaceID string) error {
|
||||
if spaceID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
|
||||
}
|
||||
if spaceID == wikiMyLibrarySpaceID {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(spaceID, "://") || strings.ContainsAny(spaceID, "/?#") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id, not a URL or path",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to discover space IDs.")
|
||||
}
|
||||
if !isDecimalWikiSpaceID(spaceID) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--space-id must be a numeric wiki space_id; do not pass a wiki node token, document token, or title",
|
||||
).WithParam("--space-id").WithHint("Run `lark-cli wiki +space-list --as user` to list accessible wiki spaces, then pass the numeric `space_id`.")
|
||||
}
|
||||
if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDecimalWikiSpaceID(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeWikiNodeListParentToken(parentNodeToken string) (string, error) {
|
||||
if parentNodeToken == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(parentNodeToken, "://") {
|
||||
ref, ok := common.ParseResourceURL(parentNodeToken)
|
||||
if !ok {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token URL is unsupported",
|
||||
).WithParam("--parent-node-token").WithHint("Pass a raw wiki node token from `wiki +node-get` or `wiki +node-list`.")
|
||||
}
|
||||
if ref.Type != "wiki" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must identify a wiki node; got a %s URL",
|
||||
ref.Type,
|
||||
).WithParam("--parent-node-token").WithHint("Resolve the document URL with `lark-cli wiki +node-get --node-token <url>` and use its `node_token`.")
|
||||
}
|
||||
parentNodeToken = ref.Token
|
||||
}
|
||||
if strings.ContainsAny(parentNodeToken, "/?#") {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a raw wiki node token, not a partial URL or path",
|
||||
).WithParam("--parent-node-token")
|
||||
}
|
||||
if !looksLikeWikiNodeToken(parentNodeToken) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--parent-node-token must be a wiki node token; do not pass a docx/sheet/base/file token",
|
||||
).WithParam("--parent-node-token").WithHint("Run `lark-cli wiki +node-get --node-token <url-or-token>` to resolve a document URL or obj_token to the wiki `node_token` first.")
|
||||
}
|
||||
if err := validateOptionalResourceName(parentNodeToken, "--parent-node-token"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parentNodeToken, nil
|
||||
}
|
||||
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken string) ([]map[string]interface{}, bool, string, error) {
|
||||
func fetchWikiNodes(runtime *common.RuntimeContext, spaceID string) ([]map[string]interface{}, bool, string, error) {
|
||||
pageSize := runtime.Int("page-size")
|
||||
startToken := strings.TrimSpace(runtime.Str("page-token"))
|
||||
parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token"))
|
||||
auto := wikiListShouldAutoPaginate(runtime)
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
|
||||
@@ -243,7 +153,7 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
|
||||
}
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
|
||||
if err != nil {
|
||||
return nil, false, "", wikiNodeListProblem(err, runtime)
|
||||
return nil, false, "", err
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
for _, item := range items {
|
||||
@@ -267,36 +177,6 @@ func fetchWikiNodes(runtime *common.RuntimeContext, spaceID, parentNodeToken str
|
||||
return nodes, lastHasMore, lastPageToken, nil
|
||||
}
|
||||
|
||||
func wikiNodeListProblem(err error, runtime *common.RuntimeContext) error {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
switch p.Code {
|
||||
case 131002:
|
||||
msg := strings.ToLower(p.Message)
|
||||
switch {
|
||||
case strings.Contains(msg, "page_token"):
|
||||
appendWikiProblemHint(err, "The page token is invalid or stale. Use only the `page_token` returned by the immediately preceding `wiki +node-list` response, or omit --page-token and start over.")
|
||||
case strings.Contains(msg, "space_id"):
|
||||
appendWikiProblemHint(err, "The --space-id value must be the numeric wiki space_id from `wiki +space-list`; do not pass a wiki URL, node token, document token, or title.")
|
||||
default:
|
||||
appendWikiProblemHint(err, "Check the wiki +node-list flags. Fix the parameter before retrying; this is not a transient error.")
|
||||
}
|
||||
case 131005:
|
||||
appendWikiProblemHint(err, "The target wiki space or parent node was not found. Re-discover the space with `wiki +space-list` and the parent with `wiki +node-list`/`wiki +node-get`; do not retry the same stale token.")
|
||||
case 131006:
|
||||
if runtime != nil && runtime.As().IsBot() {
|
||||
appendWikiProblemHint(err, "The bot/app identity cannot read this wiki space or node. Grant the app the required wiki scope and ensure the app or bot has access to the target knowledge space.")
|
||||
} else {
|
||||
appendWikiProblemHint(err, "The current user cannot read this wiki space or node. Switch to a user with access or ask the space owner to grant read permission.")
|
||||
}
|
||||
case 99991400:
|
||||
appendWikiProblemHint(err, "Rate limited by the wiki API. Stop immediate retries and retry later with exponential backoff or a smaller --page-limit.")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func wikiNodeListItem(m map[string]interface{}) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"space_id": common.GetString(m, "space_id"),
|
||||
|
||||
@@ -21,25 +21,38 @@ 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)
|
||||
**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. **创建文档**(从零创建 / 导入)→ 按顺序读取:
|
||||
1. [`lark-doc-design-philosophy.md`](references/lark-doc-design-philosophy.md)。
|
||||
2. [`lark-doc-create.md`](references/lark-doc-create.md)。
|
||||
3. 格式文件二选一:默认读 [`lark-doc-xml.md`](references/lark-doc-xml.md);用户明确 Markdown、提供 `.md` 文件或要求导入 Markdown 时,改读 [`lark-doc-md.md`](references/lark-doc-md.md)。
|
||||
4. **编辑已有文档** → 按顺序读取:
|
||||
1. 先判断是否需要 Philosophy:简单替换、错别字、日期 / 人名 / 数字替换不读;重写、润色、排版、补正文 / 图表、调结构、保真改写时,加读 [`lark-doc-design-philosophy.md`](references/lark-doc-design-philosophy.md),并按需只读一个体裁 router。
|
||||
2. [`lark-doc-update.md`](references/lark-doc-update.md)。
|
||||
3. 需要读取现状或写后验证时,按第 2 条读取 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)。
|
||||
4. 格式文件二选一:默认读 [`lark-doc-xml.md`](references/lark-doc-xml.md);用户明确 Markdown、提供 `.md` 文件或要求写入 Markdown 时,改读 [`lark-doc-md.md`](references/lark-doc-md.md)。
|
||||
|
||||
**Philosophy 负责创建与复杂编辑的设计判断;Router 负责一级大类模板与必要二类分流;Genre 只在 workplace/report 命中时补充细分偏好;Create / Update 负责执行;XML / Markdown 负责合法表达。**
|
||||
|
||||
**未读完命中的必读文件就执行相应操作,容易导致参数选择错误、格式错误、结构失焦或内容破坏。**
|
||||
|
||||
> **格式选择规则(全局):**
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;已有文档改写按 [`lark-doc-update.md`](references/lark-doc-update.md) 的 Observe-Diagnose-Patch Loop 先 fetch 再局部 patch;明确旧文本 → 新文本的简单替换可直接 `str_replace`,但写后必须 fetch 验证;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」处理:每次更新后都按 block ID 已变更处理;需要继续或重复修改时,先重新 fetch 最新内容和 block ID,不要复用旧 fetch 结果
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||
- 新增画板按复杂度处理:简单 Mermaid / SVG 图可由主 Agent 直接写入草稿;复杂图或需要专门视觉设计的 SVG 交给 SubAgent 产出完整 `<whiteboard type="svg">...</whiteboard>`;特别复杂或已有画板更新,主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||
|
||||
50
skills/lark-doc/references/genres/README.md
Normal file
50
skills/lark-doc/references/genres/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Lark Doc Genre Routers
|
||||
|
||||
本目录按“一级 router 大类模板 →(仅 report/workplace 可选)二类体裁文件”组织文档体裁指南。不要一次读取整个目录。
|
||||
|
||||
使用流程:
|
||||
|
||||
1. 先读 `../lark-doc-design-philosophy.md` 的 `Route by genre`。
|
||||
2. 选择一个最匹配的 `_router-*.md`。
|
||||
3. 多数 router 本身就是一级大类模板,提供写作思路、风格、结构、元素、反模板和 final check;读完即停。
|
||||
4. 只有 `_router-report.md` 和 `_router-workplace.md` 保留二类 routing。它们明确命中具体体裁时,只读取一个具体体裁文件。
|
||||
5. 体裁文件提供结构偏好,不是固定模板。用户明确要求、已有文档风格和事实材料优先于体裁默认结构。
|
||||
|
||||
## Routers
|
||||
|
||||
- `_router-creative.md` — 文学故事大类模板
|
||||
- `_router-media.md` — 资讯媒体大类模板
|
||||
- `_router-opinion.md` — 观点评论大类模板
|
||||
- `_router-knowledge.md` — 知识教程大类模板
|
||||
- `_router-report.md` — 报告研究大类模板 + 二类 routing
|
||||
- `_router-consumer.md` — 种草消费大类模板
|
||||
- `_router-marketing.md` — 营销转化大类模板
|
||||
- `_router-workplace.md` — 职场协作大类模板 + 二类 routing
|
||||
- `_router-personal-brand.md` — 个人品牌大类模板
|
||||
- `_router-platform.md` — 平台原生大类模板
|
||||
|
||||
## Retained second-level genre files
|
||||
|
||||
`_router-report.md` may route to:
|
||||
|
||||
- `research-report.md`
|
||||
- `data-report.md`
|
||||
- `white-paper.md`
|
||||
- `business-analysis.md`
|
||||
|
||||
`_router-workplace.md` may route to:
|
||||
|
||||
- `memo-brief.md`
|
||||
- `weekly-report.md`
|
||||
- `proposal.md`
|
||||
- `formal-doc.md`
|
||||
- `official-redhead.md`
|
||||
- `meeting-minutes.md`
|
||||
- `retrospective.md`
|
||||
- `prd.md`
|
||||
- `technical-doc.md`
|
||||
- `sop-tutorial.md`
|
||||
|
||||
## Expansion rule
|
||||
|
||||
新增二类体裁前,先确认一级大类模板无法满足当前任务;新增后必须同步更新对应 router 和 `../lark-doc-design-philosophy.md` 的路由说明,避免 agent 读取多个无关文件。
|
||||
44
skills/lark-doc/references/genres/_router-consumer.md
Normal file
44
skills/lark-doc/references/genres/_router-consumer.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Genre Template: Consumer / 种草消费大类
|
||||
|
||||
本文件是一级大类模板。适用:小红书笔记、测评、合集、好物分享、探店、生活方式内容等以体验、选择和信任为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先确定读者场景:想买、想避坑、想比较、想收藏、想获得生活灵感,还是想验证真实体验。
|
||||
- 把推荐理由写成“场景 → 标准 → 体验 / 证据 → 适合谁 / 不适合谁”。
|
||||
- 没有真实材料时,不要伪装“亲测”“真实体验”“用了一周”。可以写成选购框架、测评提纲或待验证清单。
|
||||
- 对价格、库存、功效、安全、资质等易变或高风险信息必须标注来源或待确认。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **体验笔记**:场景钩子 → 核心感受 → 关键细节 → 适合 / 不适合 → 总结建议。
|
||||
- **测评 / 横评**:评测标准 → 对象对比 → 优缺点 → 场景匹配 → 结论。
|
||||
- **合集 / 清单**:推荐标准 → 分类 → 单项理由 → 选择建议 → 注意事项。
|
||||
- **探店 / 旅行体验**:位置 / 场景 → 体验动线 → 亮点 / 雷点 → 人群建议 → 实用信息。
|
||||
|
||||
## 风格
|
||||
|
||||
- 可信、具体、有生活感;少用夸张口号。
|
||||
- 可以轻松,但不能为了种草编造体验、效果或用户反馈。
|
||||
- 标题可强调场景和利益点,但不要制造绝对化承诺。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 对比表、适合 / 不适合列表、评分维度、清单、注意事项 callout。
|
||||
- 多产品、多店铺、多路线时用 table;情绪化体验可用短段落和列表。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要“闭眼入”“天花板”“全网最强”等无证据绝对话术。
|
||||
- 不要只有夸赞,没有限制、缺点或适用人群。
|
||||
- 不要把广告文案伪装成真实测评。
|
||||
- 不要堆 emoji 和感叹号掩盖信息不足。
|
||||
- 不要忽略价格、规格、地点、时间等关键上下文。
|
||||
|
||||
## Final check
|
||||
|
||||
- 读者能否判断这是否适合自己?
|
||||
- 推荐标准和证据是否明确?
|
||||
- 是否说明缺点、限制或不适合人群?
|
||||
- 是否避免伪造体验、销量、功效和背书?
|
||||
- 收藏、购买、到店或下一步行动是否清楚?
|
||||
44
skills/lark-doc/references/genres/_router-creative.md
Normal file
44
skills/lark-doc/references/genres/_router-creative.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Genre Template: Creative / 文学故事大类
|
||||
|
||||
本文件是一级大类模板。适用:网文、短篇故事、同人、互动小说、剧本、故事大纲等以故事体验为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先判断读者要获得什么体验:爽感、悬疑、治愈、幽默、代入、角色陪伴、世界观探索,还是创作协助。
|
||||
- 用“人物欲望 → 阻力 → 选择 → 代价 → 变化”组织故事,不要只堆设定。
|
||||
- 开篇优先给处境、冲突、问题或画面;设定应在行动中露出,而不是先写百科。
|
||||
- 若用户给了 IP、角色或世界观,只使用用户提供或可确认的信息;不确定的设定标注为创作假设。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **故事正文**:钩子场景 → 人物目标 → 冲突升级 → 关键选择 → 结果 / 余味。
|
||||
- **故事大纲**:一句话 premise → 主角与欲望 → 世界规则 → 主线冲突 → 阶段节点 → 结局方向。
|
||||
- **剧本 / 分镜**:场景信息 → 动作线 → 对白 → 情绪 / 镜头提示;不要把解释塞进对白。
|
||||
- **互动叙事**:当前状态 → 选择项 → 后果预期 → 分支收束点。
|
||||
|
||||
## 风格
|
||||
|
||||
- 具体画面优先于抽象评价;用动作、细节、对话呈现情绪。
|
||||
- 叙述节奏服务体裁:悬疑保留信息差,爽文强化目标和反馈,治愈文放慢感受,剧本保持可表演。
|
||||
- 保持角色声音一致;角色不能只替作者讲道理。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 人物卡、关系表、时间线、章节节点、场景清单、对白块、分支表。
|
||||
- 长篇或复杂世界观可用 table 管理人物 / 阵营 / 线索;流程复杂时再考虑 whiteboard。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要用“在一个……的世界里”替代具体开场。
|
||||
- 不要先写大段设定、背景、人物履历,再进入故事。
|
||||
- 不要把人物写成价值观传声筒。
|
||||
- 不要用连续反转掩盖动机不足。
|
||||
- 不要在同一段里混用小说叙述、剧本格式和大纲语气。
|
||||
|
||||
## Final check
|
||||
|
||||
- 第一屏是否有足够明确的钩子或人物处境?
|
||||
- 主角想要什么、阻力是什么、代价是什么是否清楚?
|
||||
- 角色语言、行动和情绪是否一致?
|
||||
- 设定是否服务冲突,而不是独立堆砌?
|
||||
- 结尾是否留下完成感、悬念或下一步创作入口?
|
||||
44
skills/lark-doc/references/genres/_router-knowledge.md
Normal file
44
skills/lark-doc/references/genres/_router-knowledge.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Genre Template: Knowledge / 知识教程大类
|
||||
|
||||
本文件是一级大类模板。适用:科普、教程、指南、攻略、FAQ、资源合集、知识库等以理解、学习和执行为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先确定读者起点:零基础、已有经验、正在排错、准备决策,还是要复用为知识库。
|
||||
- 把读者任务拆成“理解概念 / 完成操作 / 做选择 / 排查问题 / 找资源”。
|
||||
- 先给最短可行路径,再解释原理和例外;不要先讲完整理论体系。
|
||||
- 对风险、前置条件、适用范围和不适用范围要明确。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **科普解释**:问题 / 误区 → 核心概念 → 例子 → 机制 → 边界。
|
||||
- **教程 / 指南**:目标 → 前置条件 → 步骤 → 验证方式 → 常见问题 → 下一步。
|
||||
- **FAQ**:问题分组 → 短答案 → 详细解释 / 操作 → 相关链接或注意事项。
|
||||
- **资源合集**:使用场景 → 推荐标准 → 分类清单 → 选择建议 → 维护说明。
|
||||
|
||||
## 风格
|
||||
|
||||
- 具体、可操作、少炫技。术语第一次出现要解释。
|
||||
- 每一步尽量可验证:读者应知道自己是否做对。
|
||||
- 不要为了显得专业而增加读者不需要的理论。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 步骤列表、checkbox、FAQ 表、术语表、对比表、代码块、风险 callout。
|
||||
- 流程复杂、依赖多或排错路径多时可用 whiteboard;简单操作用列表更好。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要用“首先我们来了解背景”拖延进入任务。
|
||||
- 不要只有原则,没有步骤或例子。
|
||||
- 不要把 FAQ 写成文章段落,让读者无法检索。
|
||||
- 不要省略前置条件、版本、权限、环境差异。
|
||||
- 不要把资源合集写成无标准的链接堆砌。
|
||||
|
||||
## Final check
|
||||
|
||||
- 读者是否能从第一屏知道自己能完成什么?
|
||||
- 前置条件、步骤、验证方式是否清楚?
|
||||
- 概念解释是否服务任务,而不是单独炫技?
|
||||
- FAQ / 表格 / 代码块是否便于检索和复用?
|
||||
- 是否标注了限制、例外和常见错误?
|
||||
45
skills/lark-doc/references/genres/_router-marketing.md
Normal file
45
skills/lark-doc/references/genres/_router-marketing.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Genre Template: Marketing / 营销转化大类
|
||||
|
||||
本文件是一级大类模板。适用:广告、软文、详情页、带货、活动、公关、私域话术、转化文案等以认知、信任和行动为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先明确目标:认知、兴趣、咨询、报名、购买、复购、安抚舆情,还是统一口径。
|
||||
- 写清楚受众、场景、痛点、承诺、证据、行动入口;不要先追求文采。
|
||||
- 卖点必须来自产品、服务、案例或用户提供资料;没有证据时写成待补充,不要编造。
|
||||
- 公关和声明类优先事实边界、责任边界和下一步动作,不能只做情绪安抚。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **广告 / 短文案**:受众场景 → 核心利益 → 记忆点 → 行动。
|
||||
- **软文 / 内容营销**:问题场景 → 误区 / 机会 → 解决方案 → 证据 → CTA。
|
||||
- **详情页 / 落地页**:首屏承诺 → 痛点 → 方案 → 卖点模块 → 证明 → FAQ → CTA。
|
||||
- **活动 / 私域话术**:对象 → 利益点 → 规则 → 时间 / 门槛 → 引导动作。
|
||||
- **公关沟通**:事实 → 影响 → 态度 / 原则 → 措施 → 后续同步机制。
|
||||
|
||||
## 风格
|
||||
|
||||
- 明确、有推动力,但不过度承诺。
|
||||
- 面向外部传播时保持品牌一致;面向私域时更像真人沟通,但不油腻。
|
||||
- 话术要短、可发送、可执行,避免大段官腔。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 卖点表、受众-痛点-利益表、FAQ、CTA、时间线、风险 callout、话术分支。
|
||||
- 多渠道活动可用 table;复杂转化链路可用 whiteboard。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要编造功效、销量、资质、奖项、背书、用户评价。
|
||||
- 不要只有华丽形容词,没有利益点和证据。
|
||||
- 不要把所有内容都写成“限时抢购”。
|
||||
- 不要忽视合规、隐私、价格、适用范围和退改规则。
|
||||
- 不要把公关稿写成自夸稿,回避核心问题。
|
||||
|
||||
## Final check
|
||||
|
||||
- 目标动作是否明确?
|
||||
- 受众、场景、痛点和核心利益是否具体?
|
||||
- 每个重要承诺是否有证据或待补充标记?
|
||||
- 结构是否降低理解和行动成本?
|
||||
- 是否避免夸大、误导和不合规表达?
|
||||
44
skills/lark-doc/references/genres/_router-media.md
Normal file
44
skills/lark-doc/references/genres/_router-media.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Genre Template: Media / 资讯媒体大类
|
||||
|
||||
本文件是一级大类模板。适用:新闻、快讯、深度报道、人物报道、访谈、媒体稿等以事实传递和公共理解为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先区分任务是“快速告知”“解释背景”“呈现人物”“整理访谈”还是“对外发布”。
|
||||
- 明确事实层级:已确认事实、来自材料的说法、推断、待确认信息必须分开。
|
||||
- 重要信息前置:谁、何时、何地、发生了什么、影响是什么、下一步是什么。
|
||||
- 多方材料冲突时,不要替任何一方下结论;呈现来源、口径和不确定性。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **快讯 / 短消息**:核心事实 → 影响 / 背景一句 → 后续进展。
|
||||
- **常规报道**:导语 → 关键事实 → 背景 → 相关方回应 → 影响与后续。
|
||||
- **解释性报道**:问题 → 为什么重要 → 时间线 / 机制 → 各方观点 → 尚待确认。
|
||||
- **人物 / 访谈**:人物切口 → 关键经历 / 观点 → 代表性细节 → 语录 / 问答 → 现实意义。
|
||||
|
||||
## 风格
|
||||
|
||||
- 准确、克制、可核验;少用情绪化形容词。
|
||||
- 标题可以清楚有信息量,但不要标题党。
|
||||
- 引述材料时区分直接引用、转述和总结;没有来源不要编造“相关人士表示”。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 时间线、事实表、人物 / 机构关系表、问答块、blockquote、待确认 callout。
|
||||
- 多事件、多主体、多时间点时优先 table;因果或时间推进复杂时可用 whiteboard。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要把评论写成新闻,不要把宣传稿写成报道。
|
||||
- 不要使用“引发热议”“业内人士认为”等无来源表达。
|
||||
- 不要为了完整性补齐不存在的细节。
|
||||
- 不要用大量背景压住最新事实。
|
||||
- 不要把采访整理成流水账;要保留问题线索和观点递进。
|
||||
|
||||
## Final check
|
||||
|
||||
- 关键事实是否前置,且可区分已确认 / 待确认?
|
||||
- 是否清楚标注来源、材料口径和事实边界?
|
||||
- 标题、导语和正文是否一致,不夸大?
|
||||
- 背景是否帮助理解,而不是稀释重点?
|
||||
- 读者读完是否知道事件影响和后续关注点?
|
||||
43
skills/lark-doc/references/genres/_router-opinion.md
Normal file
43
skills/lark-doc/references/genres/_router-opinion.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Genre Template: Opinion / 观点评论大类
|
||||
|
||||
本文件是一级大类模板。适用:时评、商业评论、文化评论、专栏、随笔、杂文、观点文章等以判断、立场和论证为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先写清楚核心判断:你到底赞成、反对、解释、警惕还是重新定义一个问题。
|
||||
- 评论不是事实复述。事实只承担证据、背景或反例功能。
|
||||
- 用“观点 → 理由 → 证据 → 反方可能意见 → 回应 → 结论”推进,而不是堆观点。
|
||||
- 观点强度要匹配证据强度;证据不足时用假设、可能、值得观察,而不是确定断言。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **直接评论**:切入点 → 核心观点 → 2-3 个论点 → 反方视角 → 结论 / 建议。
|
||||
- **商业 / 文化观察**:现象 → 误读 → 深层机制 → 案例 / 证据 → 可迁移启发。
|
||||
- **随笔 / 专栏**:具体经验或场景 → 观察 → 转折 → 思考展开 → 收束到一个余味。
|
||||
|
||||
## 风格
|
||||
|
||||
- 可以有锋芒,但必须清楚、公允、有边界。
|
||||
- 少用宏大词,多用具体判断;避免“时代洪流”“底层逻辑”等空泛表达。
|
||||
- 随笔可保留个人声音,但不能编造经历或把感受包装成事实。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 论点列表、论据表、正反观点表、引用块、结论 callout。
|
||||
- 长文可用小标题表达论证推进;不要为了整齐把每节写成同样长度。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要只有态度,没有论证。
|
||||
- 不要把单个案例扩大成普遍规律。
|
||||
- 不要把复杂问题写成二元对立。
|
||||
- 不要用情绪化标签替代分析。
|
||||
- 不要开头抛概念,正文不断换概念。
|
||||
|
||||
## Final check
|
||||
|
||||
- 核心观点能否用一句话复述?
|
||||
- 每个论点是否有事实、案例或逻辑支撑?
|
||||
- 是否处理了最强的反方意见?
|
||||
- 语气是否有分寸,避免过度断言?
|
||||
- 结尾是否完成判断,而不是只留下口号?
|
||||
45
skills/lark-doc/references/genres/_router-personal-brand.md
Normal file
45
skills/lark-doc/references/genres/_router-personal-brand.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Genre Template: Personal Brand / 个人品牌大类
|
||||
|
||||
本文件是一级大类模板。适用:简历、自我介绍、作品集、朋友圈、个人主页、成长复盘等以可信呈现个人经历、能力和态度为核心的文档。读完本文件即可开始写作;当前不继续读取具体体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先判断目标读者:招聘方、面试官、合作方、潜在客户、同事、朋友,还是未来的自己。
|
||||
- 个人表达要围绕“身份定位 → 关键经历 → 可验证成果 → 做事方式 → 下一步意图”。
|
||||
- 求职 / 合作场景优先事实、证据、结果;社交 / 复盘场景可以保留个性和情绪。
|
||||
- 不要夸大 title、职责、数据、奖项或项目归属;不确定的成果用“参与 / 支持 / 负责部分”区分。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **简历 / 个人主页**:定位摘要 → 核心能力 → 代表经历 / 项目 → 成果证据 → 联系 / 下一步。
|
||||
- **自我介绍**:一句话身份 → 相关经历 → 能提供的价值 → 与当前场景的连接。
|
||||
- **作品集**:选择标准 → 项目卡片 → 背景 / 角色 / 动作 / 结果 → 反思或方法论。
|
||||
- **成长复盘**:阶段目标 → 关键事件 → 做对 / 做错 → 认知变化 → 下一阶段计划。
|
||||
- **朋友圈 / 社交动态**:具体场景 → 真实感受 → 一个观点或行动,而不是履历堆砌。
|
||||
|
||||
## 风格
|
||||
|
||||
- 真实、具体、有选择。让事实说话,少用“优秀、负责、抗压、热爱”等自评词。
|
||||
- 面向职业机会时克制清晰;面向社交表达时自然但不失分寸。
|
||||
- 数据、案例和作品链接比泛泛形容更有说服力。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 项目表、能力矩阵、STAR / CAR 结构、时间线、作品卡、成果清单、下一步 checkbox。
|
||||
- 作品集或个人主页可用 grid;简历类避免过度装饰。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要把经历包装到失真。
|
||||
- 不要堆 buzzword,缺少具体项目和结果。
|
||||
- 不要把自我介绍写成长篇自传。
|
||||
- 不要把成长复盘写成鸡汤或年度流水账。
|
||||
- 不要在不同章节使用冲突的人设和定位。
|
||||
|
||||
## Final check
|
||||
|
||||
- 读者是否能快速理解“这个人是谁、能做什么、证据是什么”?
|
||||
- 关键经历是否具体到角色、动作、结果?
|
||||
- 是否避免夸大或模糊个人贡献?
|
||||
- 语气是否符合场景,既不过度自夸也不过度谦虚?
|
||||
- 下一步联系方式、作品入口或行动是否清楚?
|
||||
45
skills/lark-doc/references/genres/_router-platform.md
Normal file
45
skills/lark-doc/references/genres/_router-platform.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Genre Template: Platform Native / 平台原生大类
|
||||
|
||||
本文件是一级大类模板。适用:用户明确要求适配公众号、知乎、微博、B站、抖音、豆瓣、Newsletter 等平台形态、语气、结构或发布习惯的任务。读完本文件即可开始写作;当前不继续读取具体平台文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先判断平台是否真的决定结构。若用户只是说“发到某平台”,但内容体裁更明确,优先内容体裁;只有明确要求平台写法时才使用本模板。
|
||||
- 平台适配是“重组入口、节奏和互动方式”,不是改变事实。
|
||||
- 保留核心信息:目标读者、事实材料、观点、证据和行动;再调整标题、开头、段落、互动和 CTA。
|
||||
- 平台口吻不能替代内容质量;不要为了热度编造经历、冲突或数据。
|
||||
|
||||
## 结构偏好
|
||||
|
||||
- **公众号 / Newsletter**:标题 → 导语 / 问题 → 主体分节 → 总结 → 行动或订阅入口。
|
||||
- **知乎**:问题复述 → 立场 / 结论 → 论据展开 → 反例 / 边界 → 总结。
|
||||
- **微博 / 短帖**:一句核心观点 → 1-3 个支撑点 → 话题 / 互动。
|
||||
- **短视频 / 口播**:前三秒钩子 → 场景 / 冲突 → 观点或步骤 → 转折 → 结尾行动。
|
||||
- **小红书 / 豆瓣**:具体体验或清单 → 个人判断 → 适合 / 不适合 → 互动或收藏点。
|
||||
|
||||
## 风格
|
||||
|
||||
- 平台语气要适度:更口语、更有节奏,但不降低事实密度。
|
||||
- 标题和开头可以强化钩子,但不能制造与正文不一致的期待。
|
||||
- 长平台重逻辑和可信度,短平台重入口和记忆点。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 标题备选、开头钩子、分节小标题、互动问题、CTA、话题标签、脚本分镜表。
|
||||
- 多平台改写时用 table 对比标题、开头、结构、CTA。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要只把文章切短就叫平台适配。
|
||||
- 不要为了“平台感”堆 emoji、网络梗和夸张词。
|
||||
- 不要把专业内容改到事实失真。
|
||||
- 不要忽略平台长度、互动入口和读者预期。
|
||||
- 不要多个平台共用同一个标题和开头。
|
||||
|
||||
## Final check
|
||||
|
||||
- 是否确认平台需求真的决定写法?
|
||||
- 核心事实和观点是否保真?
|
||||
- 标题、开头和 CTA 是否符合平台读者任务?
|
||||
- 语气是否平台化但不过度表演?
|
||||
- 是否保留了足够的信息密度和可验证性?
|
||||
65
skills/lark-doc/references/genres/_router-report.md
Normal file
65
skills/lark-doc/references/genres/_router-report.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Genre Router: Report / 报告研究大类
|
||||
|
||||
本文件既是一级大类模板,也保留报告类二类 routing。适用:行业报告、数据报告、调研报告、白皮书、商业分析、竞品分析等以证据、洞察和决策为核心的文档。
|
||||
|
||||
读取规则:先使用本文件完成大类判断;只有当用户意图明确命中下方二类,且需要更细结构时,才额外读取一个具体体裁文件。不要读取多个报告体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先确定报告任务:解释现状、发现问题、评估机会、支持决策、对外建立权威,还是沉淀研究。
|
||||
- 区分事实、数据、样本、假设、洞察和建议;不要把相关性写成因果。
|
||||
- 结论要前置,证据要可追溯,建议要说明取舍、风险和适用边界。
|
||||
- 如果资料不足,先列出缺口和假设,不要填充看似完整但不可验证的结论。
|
||||
|
||||
## 默认结构
|
||||
|
||||
- Executive summary / 一句话结论
|
||||
- 背景与问题定义
|
||||
- 方法、样本或数据口径
|
||||
- 关键发现 / 洞察
|
||||
- 影响评估 / 机会与风险
|
||||
- 建议动作 / 决策项
|
||||
- 附录:数据、访谈、定义、来源
|
||||
|
||||
## 风格
|
||||
|
||||
- 客观、克制、证据驱动;少用营销语和未经验证的趋势判断。
|
||||
- 标题要表达发现,而不是只写主题。例如“留存下滑主要来自新用户首周激活不足”优于“留存分析”。
|
||||
- 建议必须能落到对象、动作、优先级或决策。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 指标表、样本说明、对比矩阵、风险雷达、假设清单、引用块、路线图、决策表。
|
||||
- 数据或多对象对比优先 table;趋势、流程、行业结构复杂时可用 whiteboard。
|
||||
|
||||
## 二类 routing
|
||||
|
||||
| 用户意图 / 信号词 | 读取文件 |
|
||||
|---|---|
|
||||
| 行业报告、调研报告、用户研究、市场研究、研究报告 | `research-report.md` |
|
||||
| 数据报告、指标分析、经营数据、漏斗分析、仪表盘解读 | `data-report.md` |
|
||||
| 白皮书、趋势报告、方法论报告、权威长报告 | `white-paper.md` |
|
||||
| 商业分析、战略分析、竞品分析、商业计划分析 | `business-analysis.md` |
|
||||
|
||||
冲突规则:
|
||||
|
||||
- 基于访谈、资料、样本或文献形成洞察时,优先 `research-report.md`。
|
||||
- 核心证据是指标、时间序列、分布、漏斗或实验时,优先 `data-report.md`。
|
||||
- 面向外部传播、体系化观点和品牌权威时,优先 `white-paper.md`。
|
||||
- 面向商业决策、竞争格局、策略选项和资源取舍时,优先 `business-analysis.md`。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要只有宏观背景,没有明确问题和结论。
|
||||
- 不要把资料摘要伪装成洞察。
|
||||
- 不要只展示数据,不解释口径、原因和行动含义。
|
||||
- 不要使用“趋势明显、空间巨大、潜力无限”等无证据判断。
|
||||
- 不要给出建议却没有优先级、风险和验证方式。
|
||||
|
||||
## Final check
|
||||
|
||||
- 结论、证据、假设和建议是否分层清楚?
|
||||
- 数据口径、样本和来源是否说明?
|
||||
- 每个洞察是否能追溯到材料或逻辑?
|
||||
- 建议是否明确对象、动作、优先级和风险?
|
||||
- 是否只额外读取了一个命中的报告二类文件?
|
||||
70
skills/lark-doc/references/genres/_router-workplace.md
Normal file
70
skills/lark-doc/references/genres/_router-workplace.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Genre Router: Workplace / 职场协作大类
|
||||
|
||||
本文件既是一级大类模板,也保留职场类二类 routing。适用:周报、方案、纪要、复盘、PRD、技术文档、SOP、公文、正式协作文档等以协作、决策、执行和留档为核心的文档。
|
||||
|
||||
读取规则:先使用本文件完成大类判断;只有当用户意图明确命中下方二类,且需要更细结构时,才额外读取一个具体体裁文件。不要读取多个职场体裁文件。
|
||||
|
||||
## 写作思路
|
||||
|
||||
- 先判断文档要推动什么:同步进展、争取决策、分配责任、固化流程、解释技术、沉淀复盘,还是正式留档。
|
||||
- 职场文档默认服务协作执行:结论、责任人、时间、状态、风险、依赖和下一步要清楚。
|
||||
- 面向领导 / 决策者时前置结论和选项;面向执行者时前置目标、范围、步骤和验收;面向归档时前置口径和可追溯性。
|
||||
- 改写已有职场文档时,保留原有事实、评论锚点、资源块和组织口径;不要为了“更完整”全文覆盖。
|
||||
|
||||
## 默认结构
|
||||
|
||||
- 目标 / 结论 / 待决策事项
|
||||
- 背景或现状,只保留影响判断的信息
|
||||
- 方案、进展、事实或流程主体
|
||||
- 风险、依赖、待确认事项
|
||||
- 行动项、责任人、时间点、验收标准
|
||||
- 附录:材料、数据、链接、术语
|
||||
|
||||
## 风格
|
||||
|
||||
- 直接、克制、可执行;少用“赋能、抓手、闭环、沉淀、拉通”等空泛词。
|
||||
- 标题表达动作和状态,例如“本周完成 X,Y 因依赖 Z 延后”优于“工作进展”。
|
||||
- 对不确定事项明确标注 owner 和截止时间。
|
||||
|
||||
## 常用元素
|
||||
|
||||
- 状态表、RACI / owner 表、时间线、风险 callout、决策矩阵、checkbox、接口表、代码块、流程图。
|
||||
- 排期、责任、指标、接口参数优先 table;流程、架构、依赖复杂时可用 whiteboard。
|
||||
|
||||
## 二类 routing
|
||||
|
||||
| 用户意图 / 信号词 | 读取文件 |
|
||||
|---|---|
|
||||
| 备忘录、领导简报、决策简报、情况说明、内部分析摘要 | `memo-brief.md` |
|
||||
| 周报、月报、进展同步、工作总结 | `weekly-report.md` |
|
||||
| 方案、项目方案、工作方案、正式方案、跨团队协作方案 | `proposal.md` |
|
||||
| 公司通知、制度规范、正式汇报、对外说明、内部正式文档 | `formal-doc.md` |
|
||||
| 红头文件、公文、请示、批复、函、通报、主送机关、抄送机关 | `official-redhead.md` |
|
||||
| 会议纪要、会议记录、议定事项、行动项 | `meeting-minutes.md` |
|
||||
| 复盘、事故总结、项目总结、阶段总结 | `retrospective.md` |
|
||||
| PRD、需求说明、用户故事、产品方案、功能设计 | `prd.md` |
|
||||
| 架构、接口、API、部署、迁移、排障、技术设计 | `technical-doc.md` |
|
||||
| SOP、操作手册、培训材料、入门指南、使用说明 | `sop-tutorial.md` |
|
||||
|
||||
冲突规则:
|
||||
|
||||
- 目标是快速帮助高层判断时,优先 `memo-brief.md`。
|
||||
- 目标是协作推进时,优先读能定义责任、时间、状态和下一步的体裁。
|
||||
- 技术 / PRD / SOP 是职场文档的专门体裁,不要用普通方案覆盖。
|
||||
- 明确公文、红头时读 `official-redhead.md`,不要用 `formal-doc.md`。
|
||||
|
||||
## 反模板
|
||||
|
||||
- 不要默认套“背景 / 目标 / 方案 / 风险 / 下一步”。
|
||||
- 不要只有愿景,没有 owner、时间和验收标准。
|
||||
- 不要把风险写成笼统提醒,没有影响、概率和应对动作。
|
||||
- 不要把技术文档写成汇报稿,或把汇报写成技术细节堆砌。
|
||||
- 不要为了排版完整而压低关键信息的优先级。
|
||||
|
||||
## Final check
|
||||
|
||||
- 读者是否知道要判断、执行或确认什么?
|
||||
- 责任人、时间点、状态、风险和下一步是否明确?
|
||||
- 结构是否匹配场景,而不是套通用模板?
|
||||
- 表格、callout、whiteboard 是否确实降低协作成本?
|
||||
- 是否只额外读取了一个命中的职场二类文件?
|
||||
35
skills/lark-doc/references/genres/business-analysis.md
Normal file
35
skills/lark-doc/references/genres/business-analysis.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Business Analysis / 商业分析
|
||||
|
||||
适用:商业分析、战略分析、竞品分析、商业计划分析、公司研究。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先确定商业问题:增长、利润、效率、竞争、组织、产品、渠道还是资本。用事实、模型和约束推导建议。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
问题 → 背景 → 市场 / 用户 → 竞争格局 → 商业模型 → 关键判断 → 方案 / 建议 → 风险。
|
||||
|
||||
## Style
|
||||
|
||||
分析导向、结论前置、避免管理黑话。模型服务判断,不是装饰。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 商业模型:whiteboard
|
||||
- 竞品对比:table
|
||||
- SWOT / 取舍:grid / table
|
||||
- 关键判断:callout
|
||||
|
||||
## Avoid
|
||||
|
||||
- 套模型不回答问题。
|
||||
- 只有观点没有证据。
|
||||
- 忽略成本、约束和风险。
|
||||
- 建议不可执行。
|
||||
|
||||
## Final check
|
||||
|
||||
- 商业问题是否明确?
|
||||
- 判断是否有证据和模型支撑?
|
||||
- 建议是否可执行?
|
||||
35
skills/lark-doc/references/genres/data-report.md
Normal file
35
skills/lark-doc/references/genres/data-report.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Data Report / 数据报告
|
||||
|
||||
适用:数据报告、指标分析、经营数据、漏斗分析、实验结果、仪表盘解读。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先确认指标口径、时间范围、分群维度和业务问题。数据报告不是罗列数字,而是解释变化、定位原因、给出动作。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
结论摘要 → 指标口径 → 总览 → 分维度分析 → 异常 / 归因 → 建议动作 → 限制。
|
||||
|
||||
## Style
|
||||
|
||||
准确、可复算、少形容词。所有关键数字都要有口径和时间范围。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 指标表:table
|
||||
- 趋势图 / 漏斗:whiteboard
|
||||
- 异常点:callout
|
||||
- 行动项:checkbox
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只列数据,不解释意义。
|
||||
- 口径不清。
|
||||
- 把相关性写成因果。
|
||||
- 选择性展示有利数据。
|
||||
|
||||
## Final check
|
||||
|
||||
- 指标口径是否清楚?
|
||||
- 变化是否有解释?
|
||||
- 建议是否能落到动作?
|
||||
35
skills/lark-doc/references/genres/formal-doc.md
Normal file
35
skills/lark-doc/references/genres/formal-doc.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Formal Document / 企业正式文档
|
||||
|
||||
适用:企业或组织内部正式文档,如公司通知、制度规范、正式汇报、立项材料、正式方案、对外说明、跨团队协作文档。不适用于党政机关公文 / 红头文件;相关任务使用 `official-redhead.md`。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先确认文档是否需要归档、追责或跨团队执行。正式文档优先事项、范围、责任、时间和口径,不追求个性化表达。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
事项型:事项 → 范围 → 安排 → 时间 → 责任。决策型:结论 → 背景 → 对比 → 风险 → 待决策。制度型:目的 → 范围 → 规则 → 例外 → 执行。
|
||||
|
||||
## Style
|
||||
|
||||
准确、克制、明确责任边界。少用夸张修饰和网感标题。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 时间 / 责任 / 范围:table
|
||||
- 风险 / 待确认:callout
|
||||
- 方案对比:table / grid
|
||||
- 流程 / 里程碑:whiteboard
|
||||
|
||||
## Avoid
|
||||
|
||||
- 开头背景过长。
|
||||
- 缺少责任人、时间、范围。
|
||||
- 为了正式感堆抽象词。
|
||||
- 为了美观强行加组件。
|
||||
|
||||
## Final check
|
||||
|
||||
- 事项、范围、责任、时间是否明确?
|
||||
- 结论和待决策是否前置?
|
||||
- 是否适合归档和追责?
|
||||
35
skills/lark-doc/references/genres/meeting-minutes.md
Normal file
35
skills/lark-doc/references/genres/meeting-minutes.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Meeting Minutes / 会议纪要
|
||||
|
||||
适用:会议纪要、会议记录、评审结论、项目例会、决策会、同步会。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先区分“已决议、待确认、个人观点、行动项”。会议纪要不是流水账,重点是结论、分歧、责任和下一步。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
会议信息 → 背景 → 讨论要点 → 决议 → 行动项 → 风险 / 待确认。
|
||||
|
||||
## Style
|
||||
|
||||
中立、准确、面向行动。尽量保留关键原话和分歧依据。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 会议信息:table
|
||||
- 决议:callout / list
|
||||
- 行动项:checkbox / table
|
||||
- 原话:blockquote
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只按发言顺序记录。
|
||||
- 责任人和截止时间缺失。
|
||||
- 把未确认观点写成决议。
|
||||
- 删除关键分歧。
|
||||
|
||||
## Final check
|
||||
|
||||
- 决议、待确认、行动项是否分开?
|
||||
- 每个行动项是否有负责人和时间?
|
||||
- 关键分歧是否保留?
|
||||
39
skills/lark-doc/references/genres/memo-brief.md
Normal file
39
skills/lark-doc/references/genres/memo-brief.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Genre: Memo / Briefing Note
|
||||
|
||||
适用:备忘录、领导简报、决策简报、情况说明、问题简报、内部分析摘要。目标是帮助读者快速掌握情况并做判断。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构:
|
||||
|
||||
- **决策简报**:一句话结论 → 背景 → 选项对比 → 推荐方案 → 风险 → 待决策问题。
|
||||
- **情况说明**:事项概述 → 当前状态 → 影响范围 → 已采取措施 → 后续安排。
|
||||
- **问题简报**:问题 → 证据 → 原因 → 影响 → 建议动作。
|
||||
- **备忘录**:To / From / Date / Subject → Purpose → Context → Discussion → Action / Recommendation。
|
||||
|
||||
## Style
|
||||
|
||||
短、准、直接。先给结论,再给依据。面向高层或跨团队读者时减少细枝末节,把关键判断、影响和动作前置。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 结论 / 建议:短段落或 callout。
|
||||
- 方案对比:table。
|
||||
- 风险和影响:table / callout。
|
||||
- 待决策问题:checkbox / list。
|
||||
- 背景材料:附录或折叠到后文,不要压住结论。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 背景过长,读者看不到结论。
|
||||
- 只有信息罗列,没有判断。
|
||||
- 只说“建议推进”,没有选项和取舍。
|
||||
- 风险轻描淡写或没有责任边界。
|
||||
- 把简报写成完整报告。
|
||||
|
||||
## Final check
|
||||
|
||||
- 第一屏是否能读到结论或建议?
|
||||
- 是否清楚说明需要读者做什么决定?
|
||||
- 选项、风险、影响和下一步是否明确?
|
||||
- 是否足够短,适合快速阅读?
|
||||
35
skills/lark-doc/references/genres/official-redhead.md
Normal file
35
skills/lark-doc/references/genres/official-redhead.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Official Redhead / 公文红头
|
||||
|
||||
适用:党政机关、事业单位、国企或正式组织语境下的公文 / 红头文件,包括通知、通报、请示、批复、函、纪要、公告、通告、报告、意见等。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先判定文种,再按文种写;公文是强约束文书,不是“更正式的通知”。缺少发文机关、主送机关、文号、依据等信息时标注占位或待确认,不要伪造红头、印章、文号或法定依据。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
通知:依据 → 事项 → 要求 → 时间。请示:缘由 → 请示事项 → 依据 → “妥否,请批示”。批复:依据 → 明确意见 → 执行要求。报告:情况 → 成效 → 问题 → 下一步,报告不得夹带请示。纪要:会议情况 → 会议明确 → 议定事项。
|
||||
|
||||
## Style
|
||||
|
||||
庄重、准确、规范、简洁。少用修饰,不用 emoji、网感表达和花哨排版。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 正文:段落 + 条款
|
||||
- 责任分工 / 附件:table
|
||||
- 议定事项:table / checkbox
|
||||
- 原则上少用 callout / grid / 颜色
|
||||
|
||||
## Avoid
|
||||
|
||||
- 文种混乱。
|
||||
- 报告夹带请示。
|
||||
- 伪造红头、印章、文号、签发人或依据。
|
||||
- 口语化、营销化。
|
||||
|
||||
## Final check
|
||||
|
||||
- 文种是否正确?
|
||||
- 发文对象、事项、依据、期限、责任是否明确?
|
||||
- 是否避免伪造格式要素?
|
||||
35
skills/lark-doc/references/genres/prd.md
Normal file
35
skills/lark-doc/references/genres/prd.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: PRD / 产品需求
|
||||
|
||||
适用:PRD、需求说明、用户故事、产品方案、功能设计、版本范围说明。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先确认用户、场景、问题和验收标准。PRD 不是愿望清单,要能被设计、研发、测试和业务评审。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
背景与问题 → 用户与场景 → 目标与非目标 → 方案概述 → 功能范围 → 交互 / 流程 → 数据与埋点 → 边界异常 → 验收标准 → 风险依赖。
|
||||
|
||||
## Style
|
||||
|
||||
具体、可评审、可验收。多写用户行为、系统响应、状态变化和规则。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 用户故事 / 验收:列表 / checkbox
|
||||
- 字段 / 规则 / 状态:table
|
||||
- 流程 / 状态机:whiteboard
|
||||
- 风险 / 待确认:callout
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只有需求描述,没有验收标准。
|
||||
- 没有非目标。
|
||||
- 缺少异常和边界。
|
||||
- 用愿景替代规则。
|
||||
|
||||
## Final check
|
||||
|
||||
- 目标、非目标和范围是否清楚?
|
||||
- 规则和状态是否可评审?
|
||||
- 验收标准是否可验证?
|
||||
35
skills/lark-doc/references/genres/proposal.md
Normal file
35
skills/lark-doc/references/genres/proposal.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Proposal / 方案
|
||||
|
||||
适用:项目方案、工作方案、正式方案、跨团队协作方案。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先定义问题、目标、约束和决策点。方案的价值在取舍,不是堆功能和计划。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
结论 / 推荐方案 → 问题定义 → 目标与范围 → 方案设计 → 资源排期 → 风险 → 待决策事项。
|
||||
|
||||
## Style
|
||||
|
||||
明确、可评审、可执行。每个方案点要能回答“为什么这样做”。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 方案对比:table
|
||||
- 路线图:whiteboard
|
||||
- 风险:callout
|
||||
- 任务拆解:checkbox
|
||||
|
||||
## Avoid
|
||||
|
||||
- 没有问题定义。
|
||||
- 目标和方案混在一起。
|
||||
- 不写取舍。
|
||||
- 没有资源、排期和风险。
|
||||
|
||||
## Final check
|
||||
|
||||
- 问题和目标是否明确?
|
||||
- 方案取舍是否说明?
|
||||
- 是否可执行和评审?
|
||||
35
skills/lark-doc/references/genres/research-report.md
Normal file
35
skills/lark-doc/references/genres/research-report.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Research Report / 调研分析报告
|
||||
|
||||
适用:调研报告、行业分析、用户研究、市场研究、研究报告、竞品分析。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先定义研究问题和结论使用场景,再搜集资料、数据、访谈或样本。事实、推断、建议要分开;没有来源、样本或口径的数据不要写成确定事实。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
摘要 → 研究问题 → 方法 / 样本 → 核心发现 → 分析 → 建议 → 限制 / 待验证。
|
||||
|
||||
## Style
|
||||
|
||||
证据驱动、结构清楚、结论克制。不要为了报告感堆图表。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 核心发现:callout
|
||||
- 样本 / 数据:table
|
||||
- 趋势 / 关系:whiteboard
|
||||
- 引用材料:blockquote
|
||||
|
||||
## Avoid
|
||||
|
||||
- 没有研究问题。
|
||||
- 资料堆砌没有洞察。
|
||||
- 推测写成事实。
|
||||
- 数据无来源或口径。
|
||||
|
||||
## Final check
|
||||
|
||||
- 研究问题是否明确?
|
||||
- 事实、推断、建议是否分开?
|
||||
- 结论是否支撑行动?
|
||||
35
skills/lark-doc/references/genres/retrospective.md
Normal file
35
skills/lark-doc/references/genres/retrospective.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Retrospective / 复盘总结
|
||||
|
||||
适用:项目复盘、事故复盘、阶段总结、周报/月报中的复盘部分。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先还原事实,再给判断,最后形成动作。复盘不是甩锅,也不是表功;价值在找到可验证的改进点。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
结论 → 事实时间线 → 影响范围 → 原因分析 → 做得好的地方 → 问题教训 → 后续动作。
|
||||
|
||||
## Style
|
||||
|
||||
事实和判断分开,语气克制。行动项必须有负责人、时间和验收方式。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 时间线:table / whiteboard
|
||||
- 根因:列表 / 鱼骨图
|
||||
- 行动项:checkbox / table
|
||||
- 风险:callout
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只有情绪评价。
|
||||
- 只有“加强对齐”没有动作。
|
||||
- 结论与证据不匹配。
|
||||
- 复盘写成甩锅材料。
|
||||
|
||||
## Final check
|
||||
|
||||
- 事实、判断、动作是否分开?
|
||||
- 根因是否有证据?
|
||||
- 行动项是否可追踪?
|
||||
36
skills/lark-doc/references/genres/sop-tutorial.md
Normal file
36
skills/lark-doc/references/genres/sop-tutorial.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Genre: SOP / 操作手册
|
||||
|
||||
适用:SOP、操作手册、培训材料、入门指南、使用说明、FAQ、流程规范。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先明确适用对象、前置条件和成功标准。步骤写作遵循“一步一个动作”:有顺序用有序列表,无顺序用项目符号;给出校验结果和异常处理。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
适用范围 → 前置条件 → 操作步骤 → 检查标准 → 异常处理 → 责任边界 / 参考链接。
|
||||
|
||||
## Style
|
||||
|
||||
直接、清楚、面向操作者。按用户看到的界面命名,不按内部实现命名。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 步骤:有序列表
|
||||
- 检查项:checkbox
|
||||
- 命令 / 配置 / 日志:pre / code
|
||||
- 错误处理:table / callout
|
||||
- 复杂流程:whiteboard
|
||||
|
||||
## Avoid
|
||||
|
||||
- 缺少前置条件。
|
||||
- 步骤跳跃。
|
||||
- 一个步骤里塞多个动作。
|
||||
- 没有失败处理。
|
||||
|
||||
## Final check
|
||||
|
||||
- 新手能否完成任务?
|
||||
- 输入、输出和校验是否清楚?
|
||||
- 每步是否只做一件事?
|
||||
35
skills/lark-doc/references/genres/technical-doc.md
Normal file
35
skills/lark-doc/references/genres/technical-doc.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Technical Document / 技术文档
|
||||
|
||||
适用:技术方案、架构设计、接口说明、API 文档、部署手册、迁移方案、排障指南、研发交接。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先确认读者要实现、集成、排查还是评审。技术文档必须准确、可复现、可验证;事实不确定要标注假设。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
问题 / 背景 → 目标与非目标 → 约束 → 总体设计 → 模块设计 → 数据 / 接口 → 风险 → 验证 → 灰度 / 回滚。
|
||||
|
||||
## Style
|
||||
|
||||
精确、朴素、可实现、可验证。术语保持一致,不为了通俗牺牲准确性。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 命令 / 代码 / 日志:pre / code
|
||||
- 参数 / 字段 / 错误码:table
|
||||
- 架构 / 调用链:whiteboard / Mermaid
|
||||
- 步骤 / 验收:列表 / checkbox
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只有概念没有接口和边界。
|
||||
- 忽略失败场景、监控和回滚。
|
||||
- 图很多但不说明数据流。
|
||||
- 营销腔描述技术收益。
|
||||
|
||||
## Final check
|
||||
|
||||
- 能否据此实现或排查?
|
||||
- 目标、非目标、边界是否清楚?
|
||||
- 是否有验证、灰度和回滚?
|
||||
35
skills/lark-doc/references/genres/weekly-report.md
Normal file
35
skills/lark-doc/references/genres/weekly-report.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: Weekly Report / 周报月报
|
||||
|
||||
适用:周报、月报、进展同步、工作总结。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先明确读者关心什么:进展、结果、风险、资源诉求还是下周计划。周报不是流水账,要把成果、阻塞和下一步前置。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
核心结论 → 本期进展 → 关键结果 / 数据 → 风险阻塞 → 下期计划 → 需要支持。
|
||||
|
||||
## Style
|
||||
|
||||
简洁、可扫描、面向协作。多写结果和动作,少写忙碌过程。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 进展表:table
|
||||
- 行动项:checkbox
|
||||
- 风险:callout
|
||||
- 数据:table
|
||||
|
||||
## Avoid
|
||||
|
||||
- 流水账。
|
||||
- 只有做了什么,没有结果。
|
||||
- 风险不前置。
|
||||
- 没有下一步和需要支持。
|
||||
|
||||
## Final check
|
||||
|
||||
- 结论是否前置?
|
||||
- 结果和风险是否清楚?
|
||||
- 下一步是否可执行?
|
||||
35
skills/lark-doc/references/genres/white-paper.md
Normal file
35
skills/lark-doc/references/genres/white-paper.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Genre: White Paper / 白皮书
|
||||
|
||||
适用:白皮书、趋势报告、方法论报告、权威长报告、品牌研究报告。
|
||||
|
||||
## Thinking guide
|
||||
|
||||
先建立一个可复用框架:背景问题、关键趋势、分析模型、案例证据和行动方法。白皮书要有体系,而不是长篇软文。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
摘要 → 背景 → 核心框架 → 趋势 / 章节分析 → 案例 / 数据 → 方法建议 → 结论。
|
||||
|
||||
## Style
|
||||
|
||||
权威、系统、克制。允许有品牌观点,但必须由证据和框架支撑。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 框架图:whiteboard
|
||||
- 趋势 / 数据:table
|
||||
- 案例:blockquote / card
|
||||
- 章节摘要:callout
|
||||
|
||||
## Avoid
|
||||
|
||||
- 变成宣传册。
|
||||
- 没有方法论框架。
|
||||
- 案例和数据不可核验。
|
||||
- 篇幅很长但结构松散。
|
||||
|
||||
## Final check
|
||||
|
||||
- 是否有清晰框架?
|
||||
- 结论是否由证据支撑?
|
||||
- 是否适合长期传播或下载?
|
||||
@@ -1,15 +1,24 @@
|
||||
# docs +create(创建飞书云文档)
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||
|
||||
## Document Authoring Loop
|
||||
|
||||
从零创作文档时,以本地草稿文件作为唯一正文源(默认 XML;用户明确要求 Markdown 或提供 `.md` 文件时使用 Markdown),主 Agent 串行完成写作、审阅和修订,草稿达标后再调用 `docs +create`。不要先创建线上骨架再让多个 Agent 并行拆章节写入;这容易造成风格漂移、重复铺陈、结构断流和事实口径不一致。
|
||||
|
||||
1. **Plan(规划)**:明确受众、目的、范围、体裁、结构和验收标准;事实来源或产品口径不确定时先询问用户,能安全假设时说明假设并继续。
|
||||
2. **Draft(起草)**:在当前工作目录创建相对路径草稿文件,主 Agent 按文档顺序串行写作;
|
||||
3. **Observe(观察)**:读取当前草稿,检查结构、重复、断流、事实、语气和格式;
|
||||
4. **Revise(修订)**:局部修订本地文件,优先调整顺序、补桥接句、删重复、统一术语和修正 XML;不追求第一稿一次完美。
|
||||
5. **Enrich(增强)**:决定一篇文档是否真正好读、可信、可传播。必要时加入表格、列表、callout、grid、画板、引用或附件;增强必须降低理解成本,不为了“丰富”而装饰。
|
||||
- 新增画板按复杂度处理:简单 Mermaid 或 SVG 图可由主 Agent 直接写入本地 XML 草稿;复杂 SVG 可启动 SubAgent 只产出完整 `<whiteboard type="svg">...</whiteboard>` 片段;特别复杂或已有画板更新,再创建空白画板并让 SubAgent 读取 `lark-whiteboard` 完成写入。
|
||||
- 创建后才能插入的图片、附件或资源,先在草稿中标记位置和素材路径,Deliver 后用 `docs +media-insert` 等命令补齐并 fetch 验证。
|
||||
6. **Validate(验证)**:检查用户要求、字数、格式、事实、可读性和风格;任何一项不满足,就回到 Observe / Revise 继续 loop。
|
||||
7. **Deliver(交付)**:本地草稿通过验证后,用 `docs +create --content @lark-doc-draft.xml` 创建 XML 文档;Markdown 草稿用 `docs +create --doc-format markdown --content @lark-doc-draft.md`。如需创建后插入图片、附件或资源,完成后再次 fetch 确认。
|
||||
|
||||
`@file` 只接受当前工作目录下的相对路径,不要传 `@/tmp/xxx.xml` 这类绝对路径。SubAgent 只用于边界清晰的视觉或资源片段(如完整 SVG whiteboard XML),主 Agent 必须审核并合并结果;不要让 SubAgent 并行撰写正文。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -65,15 +74,9 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
||||
| `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) |
|
||||
| `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) |
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容
|
||||
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
|
||||
|
||||
## 参考
|
||||
|
||||
- [`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-design-philosophy.md`](lark-doc-design-philosophy.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) — 更新文档
|
||||
|
||||
120
skills/lark-doc/references/lark-doc-design-philosophy.md
Normal file
120
skills/lark-doc/references/lark-doc-design-philosophy.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Lark Doc Design Philosophy
|
||||
|
||||
- 把飞书云文档当作帮助读者完成理解、判断、决策或行动的界面,而不是内容容器。你的任务不是把信息铺满页面,而是为具体读者设计一条清晰、可信、可执行的阅读路径。
|
||||
- 本文件负责文档设计判断:读者任务、写作循环、结构取舍、表达组件、一级体裁路由、反模板化和质量自检。具体执行流程见 `lark-doc-create.md` 和 `lark-doc-update.md`;XML / Markdown 语法见 `lark-doc-xml.md` / `lark-doc-md.md`;体裁路由按下方表格读取一个 `genres/_router-*.md`。除 `genres/_router-workplace.md` 和 `genres/_router-report.md` 外,router 本身就是一级大类模板,不再继续读取具体体裁文件。
|
||||
|
||||
## 0. Priority order
|
||||
|
||||
当目标冲突时,按此顺序取舍:**事实准确性 > 信息层级 > 可扫描性 > 协作可执行性 > 视觉整洁 > 个性化表达**。事实不确定就标注假设 / 待确认;结构混乱先调信息层级;视觉和个性只能增强理解,不能牺牲事实、结构和执行。
|
||||
|
||||
## 1. Authoring loop
|
||||
|
||||
非微编辑的创建、重写、润色、排版或生成较多正文任务,先走一轮按复杂度缩放的写作循环。简单润色 / 排版只做快速内部检查;复杂创建、重写、汇报、决策或传播文档才展开候选方案。
|
||||
|
||||
1. **集思广益**:基于用户目标和材料,在内部快速构思 2-3 个候选组织方式;复杂文档可比较决策稿、执行说明、知识库、传播稿等形态,简单任务只保留一个最自然的结构。
|
||||
2. **探索**:确认读者、文档工作、事实来源、已有风格和协作约束;编辑已有文档时,额外确认必须保留的评论、图片、附件、画板或资源块。事实不确定时标注假设 / 待确认,不要编造确定性。
|
||||
3. **计划**:按下方体裁路由选择一个最匹配的 router,确定信息顺序、证据密度、语气、组件集合,以及是否需要且最多一个主组织锚点。每个 rich block 都必须能回答:它降低了什么理解、比较、判断或行动成本?
|
||||
4. **批判性思考(写前)**:检查计划是否像任何类似请求都能套用的通用模板,是否跳过了关键读者任务,是否过度组件化,是否会破坏已有结构或资源块;发现问题先修改计划。
|
||||
5. **构建**:按修改后的计划执行 create / update。标题、顺序、列表、表格、callout、grid、whiteboard 都从真实信息关系推导,不为了“丰富”“高级”“专业”临时加装饰。
|
||||
6. **再次批判性思考(写后)**:创建后复读草稿 / 结果,更新后 fetch 验证;检查事实保真、结构推进、组件必要性、术语一致、阅读负担和下一步是否清晰;发现不服务当前读者、任务或体裁的亮点 / 装饰,就删除或降级。
|
||||
|
||||
## 2. Ground it in the document job
|
||||
|
||||
写作或改写前,先判断这篇文档的工作:
|
||||
|
||||
- 读者是谁:决策者、执行者、评审者、客户、开发者、运营者,还是未来维护者?
|
||||
- 读者要完成什么:理解背景、做出决策、执行任务、评审方案、排查问题、学习方法、传播内容,还是留档备查?
|
||||
- 读完后读者应该能做什么:拍板、执行、确认风险、复用、转发、评论、排查或完成任务?
|
||||
- 文档寿命是什么:一次性沟通、项目周期内维护、长期知识库、正式归档,还是外部传播?
|
||||
|
||||
如果用户没有明确这些信息,能安全假设时说明假设并继续;事实来源、产品口径、结论归属不确定时,先询问或标注待确认,不要编造确定性。
|
||||
|
||||
## 3. Route by genre
|
||||
|
||||
体裁决定结构、语气、证据密度和表达方式。不要用同一套“背景 / 目标 / 方案 / 风险 / 下一步”应对所有任务。
|
||||
|
||||
读取规则:
|
||||
|
||||
1. 只选择一个最匹配的路径读取。
|
||||
2. 多数 `_router-*` 文件本身就是一级大类模板,读完即停。
|
||||
3. 只有 `genres/_router-workplace.md` 和 `genres/_router-report.md` 保留二类 routing;当 router 明确命中具体体裁时,只额外读取一个具体体裁文件。
|
||||
4. 简单编辑已有文档时,优先沿用原文体裁和风格,不额外读取体裁文件。
|
||||
|
||||
| 路径 | 类别 | 用户意图 |
|
||||
|---|---|---|
|
||||
| `genres/_router-workplace.md` | 职场协作 | 周报、方案、纪要、复盘、PRD、技术文档、SOP、公文、正式协作文档 |
|
||||
| `genres/_router-creative.md` | 文学故事 | 网文、短篇故事、同人、互动小说、剧本、故事大纲 |
|
||||
| `genres/_router-media.md` | 资讯媒体 | 新闻、快讯、深度报道、人物报道、访谈、媒体稿 |
|
||||
| `genres/_router-opinion.md` | 观点评论 | 时评、商业评论、文化评论、专栏、随笔、杂文、观点文章 |
|
||||
| `genres/_router-knowledge.md` | 知识教程 | 科普、教程、指南、攻略、FAQ、资源合集、知识库 |
|
||||
| `genres/_router-report.md` | 报告研究 | 行业报告、数据报告、调研报告、白皮书、商业分析、竞品分析 |
|
||||
| `genres/_router-consumer.md` | 种草消费 | 小红书笔记、测评、合集、好物分享、探店、生活方式 |
|
||||
| `genres/_router-marketing.md` | 营销转化 | 广告、软文、详情页、带货、活动、公关、私域话术、转化文案 |
|
||||
| `genres/_router-personal-brand.md` | 个人品牌 | 简历、自我介绍、作品集、朋友圈、个人主页、成长复盘 |
|
||||
| `genres/_router-platform.md` | 平台原生 | 用户明确要求适配公众号、知乎、微博、B站、抖音、豆瓣、Newsletter 等平台形态 |
|
||||
|
||||
如果任务混合多个方向,优先选择决定**内容结构**的 router;平台只是发布载体,除非用户明确要求“改成某平台内容 / 按某平台写法”,否则不要为了平台语气读取平台 router。例如“写给领导看的技术方案”优先读 `_router-workplace.md`,并采用正式克制语气;“把技术方案改成公众号文章”优先读 `_router-platform.md`,同时保留技术事实准确性。
|
||||
|
||||
## 4. Structure is information
|
||||
|
||||
- 结构不是装饰。标题、顺序、列表、表格、callout、grid、画板和引用块,都应该表达真实的信息关系。
|
||||
- 选择结构前先判断:这是顺序、并列、对比、因果、层级、状态、证据,还是行动关系?读者需要先知道什么,才能理解后面的内容?哪些信息应该前置为结论,哪些适合压缩或放到附录?
|
||||
- 文档开头不是固定的“背景”。决策文档先给结论和待拍板问题;执行文档先给目标、范围和时间线;复盘文档先给事实结论、影响和后续动作;技术文档先给适用范围、前置条件和快速路径;传播文档先给痛点、利益点或钩子。
|
||||
- 较长、复杂或面向传播 / 汇报 / 决策的文档,最好有一个主组织锚点,如一句话结论、决策矩阵、风险雷达、路线图、架构图、时间线、对比表或 checklist。只保留一个,不要到处制造亮点;短文档、微编辑、正式归档或简单 SOP 不必强行设计。锚点必须服务事实准确、信息层级和协作执行,不能为了个性化表达增加理解成本。
|
||||
|
||||
## 5. Rich blocks must earn their place
|
||||
|
||||
飞书富 block 是表达手段,不是装饰指标。不要为了“丰富”“高级”“专业”而加入固定比例的 callout、grid、table 或 whiteboard,优先选择最能降低理解成本的表达方式。
|
||||
|
||||
| 信息关系 / 场景 | 优先表达 |
|
||||
|---|---|
|
||||
| 判断、背景、解释、理由 | 段落 / 小标题 |
|
||||
| 并列项、短步骤、注意点 | 列表 |
|
||||
| 待办、检查项、验收项 | checkbox |
|
||||
| 多对象、多字段、排期、指标、接口参数 | table |
|
||||
| 少数组并列信息、轻量对比、模块卡片 | grid |
|
||||
| 风险、限制、例外、强约束、待确认 | callout |
|
||||
| 外部意见、原话、会议纪要、引用材料 | blockquote |
|
||||
| 代码、命令、配置、日志 | pre / code |
|
||||
| 流程、架构、依赖、路线图、因果链路、复杂模型 | whiteboard |
|
||||
|
||||
- 组件由信息关系决定,不由重要度决定。重要结论可以是段落、callout、表格或画板,选择标准是它能否让读者更快理解、比较、判断或行动。
|
||||
- 关键章节需要判断是否存在图示机会,画板用于图示明显降低理解成本的关系:流程、架构、依赖、时间线、因果、分层、组织关系、复杂对比。结构简单或文字更清楚时,不要强行画板化。具体 Mermaid / SVG / SubAgent 路由见 `lark-doc-whiteboard.md`。
|
||||
- 颜色只在帮助识别语义时使用;正式文档、技术文档、长期知识库和归档材料优先保持朴素。
|
||||
|
||||
## 6. Writing style
|
||||
|
||||
- 从读者一侧写,而不是从系统实现一侧写。用读者能识别和控制的事物命名,默认使用具体、主动、可执行的表达。
|
||||
- 具体比聪明更好。一致比花哨更重要。文档词汇是读者导航的路标,同一动作、状态、对象在全文中保持同名。
|
||||
|
||||
## 7. Calibrate against AI-doc defaults
|
||||
|
||||
写作或改写前,主动避开这些 AI 文档默认值:
|
||||
|
||||
1. 不管任务是什么都套“背景 / 目标 / 方案 / 风险 / 下一步”。
|
||||
2. 用“赋能、闭环、抓手、沉淀、拉通、对齐”等空泛词替代具体动作。
|
||||
3. 开头写摘要,但没有结论、取舍、风险或行动。
|
||||
4. 为了“丰富”强行加入 callout、table、grid 或 whiteboard。
|
||||
5. 把所有重要信息都画板化,或者把简单关系复杂图示化。
|
||||
6. 每篇文档都强行设计一句话结论、决策矩阵、风险雷达、路线图等主组织锚点。
|
||||
7. 标题整齐,但章节之间没有真实推进关系。
|
||||
8. 章节之间像多个 Agent 拼接,术语、语气、事实口径不一致。
|
||||
9. 改写已有文档时全文覆盖,破坏评论、引用、图片、画板或资源块。
|
||||
|
||||
如果你的计划看起来像面对任何类似请求都会写出的文档,就修改它,使结构、语气和组件选择更贴合当前读者、任务和体裁。
|
||||
|
||||
## 8. Final self-check
|
||||
|
||||
交付前检查:
|
||||
|
||||
| 指标 | 自检问题 |
|
||||
|---|---|
|
||||
| 读者任务 | 这篇文档是否帮助目标读者完成理解、判断、决策或行动? |
|
||||
| 体裁匹配 | 结构、语气和证据密度是否符合当前体裁? |
|
||||
| 信息结构 | 标题、顺序、列表、表格和组件是否表达真实关系? |
|
||||
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多? |
|
||||
| 组件必要性 | callout、grid、table、whiteboard 是否真的降低理解成本? |
|
||||
| 风格一致 | 是否延续用户给定样例或已有文档风格? |
|
||||
| 事实保真 | 改写时是否保留事实、引用、图片、附件、资源块和关键措辞? |
|
||||
| 行动清晰 | 读者下一步应该做什么是否明确? |
|
||||
| 反模板化 | 是否拒绝加入或删除了不服务当前读者、任务或体裁的通用亮点 / 装饰? |
|
||||
@@ -1,13 +1,6 @@
|
||||
|
||||
# docs +update(更新飞书云文档)
|
||||
|
||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作。
|
||||
|
||||
> **⚠️ 格式选择规则:**
|
||||
@@ -16,6 +9,26 @@
|
||||
>
|
||||
> **Markdown 局限 & block ID 前提:** Markdown 不携带 block ID,也无样式(颜色、对齐、callout 等)。需要按 block ID 定位(`block_*` 指令的 `--block-id`)时,先 `docs +fetch --detail with-ids` **配合 `--scope`(`outline` / `range` / `keyword` / `section`)局部获取**目标段落,不要全量 fetch。拿到 block ID 后 `--content` 仍可用 Markdown,只是写入内容不带样式。
|
||||
|
||||
## Observe-Diagnose-Patch Loop
|
||||
|
||||
适用于修改已有飞书文档:调整语气、精简冗余、增补章节、修复结构混乱、按领导意见修改,或在已有图片、引用、表格、评论、资源块的文档上做保真改写。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 核心原则:先观察,再诊断,再局部 patch,最后 fetch 验证。这个流程比全文重写安全;除非用户明确要求完全重建,或文档确实已无保留价值,不要轻易使用 `overwrite`,否则会丢失评论和未支持的资源。
|
||||
> 每次 `docs +update` 后,都按 block ID 已发生变更处理。如果需要继续修改、重复修改同一处内容,或引用刚插入 / 替换后的内容,必须先重新 `docs +fetch --detail with-ids` 拉取最新内容和 block ID,再执行下一轮 patch。
|
||||
|
||||
1. **Observe(读取现状)**:先 `docs +fetch` 读取当前文档状态,并按意图选择最小范围。
|
||||
- 改某一节或大文档:先 `--scope outline --max-depth 2` 找章节,再 `--scope section --start-block-id <标题id> --detail with-ids`
|
||||
- 精确跨节区间:用 `--scope range --start-block-id xxx --end-block-id yyy`
|
||||
- 只有模糊关键词:用 `--scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||
- 明确整篇重构才读 `--detail with-ids` 全文;只读摘要或确认事实时用更轻的 fetch
|
||||
2. **Diagnose(诊断问题)**:判断用户目标、当前结构、语气、重复、断流、事实口径和需要保留的资源;识别哪些 block 必须原样保留。
|
||||
3. **Patch Plan(制定局部计划)**:把修改拆成最小安全操作:简单行内替换用 `str_replace`;整段/整块重写用 `block_replace`;增补章节用 `block_insert_after`;删冗余用 `block_delete`;调整顺序用 `block_move_after`。
|
||||
4. **Patch(精确修改)**:按 block / section 执行局部命令。保护 `<cite>`、`<img>`、`<source>`、`<whiteboard>`、`<sheet>`、`<bitable>`、`<synced_reference>` 等 token 化内容,不要改成纯文本或占位符。同一 block 的多处修改合并成一次 `block_replace`。
|
||||
5. **Verify(fetch 验证)**:每轮写操作后按影响范围重新 fetch,检查用户要求、结构、语气、事实、资源块和 block ID 是否符合预期;不满足就基于最新 fetch 结果继续 Diagnose / Patch,不要沿用上一轮 block ID。
|
||||
|
||||
复杂结构重组时,优先“先插入新结构,再删除旧 block”:用 `block_insert_after` 插入 grid / table / callout / 新章节,再用 `block_delete` 删除旧段落。这样比 `overwrite` 更能保住图片、评论、引用、资源块和不相关内容。`str_replace` 的匹配范围取决于格式:XML 模式只适合行内匹配;Markdown 模式可跨行和使用 `前缀...后缀`,但跨 block 或容器级重写仍优先用 block 指令。
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
@@ -45,12 +58,12 @@
|
||||
|
||||
## Block ID 生命周期
|
||||
|
||||
写操作后不要默认复用之前 fetch 到的 block ID:
|
||||
安全规则:每次写操作后都按 block ID 已变更处理。需要连续修改、重复修改或操作新插入内容时,必须重新 fetch 最新内容和 block ID;不要默认复用之前 fetch 到的 block ID。
|
||||
|
||||
- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前重新 fetch
|
||||
- `block_insert_after` / `append` / `block_copy_insert_after`:锚点 / 源 ID 通常保留,新内容是新 ID;要操作新内容先重新 fetch
|
||||
- `block_move_after`:被移动 ID 通常保留,但位置、章节、range 语义变化;后续依赖位置时重新 fetch
|
||||
- `str_replace`:简单行内替换通常不改变 ID;跨行 / 大段替换后如继续 block 级操作,先重新 fetch
|
||||
- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前必须重新 fetch
|
||||
- `block_insert_after` / `append` / `block_copy_insert_after`:新内容一定是新 ID;要操作新内容或继续编辑插入点附近内容,先重新 fetch
|
||||
- `block_move_after`:位置、章节、range 语义已变化;后续依赖位置或章节边界时重新 fetch
|
||||
- `str_replace`:即使是简单行内替换,也不要在后续 block 级操作中假设旧 ID 仍正确;跨行 / 大段替换后必须重新 fetch
|
||||
|
||||
## 指令示例
|
||||
|
||||
@@ -197,63 +210,15 @@ lark-cli docs +update --doc "<doc_id>" --command block_move_after \
|
||||
| `warnings` | 警告信息列表 |
|
||||
| `document.new_blocks` | 本次操作新增的 block 列表(如画板)。`block_id` 可用于后续精确编辑;`block_token` 是资源块 token(如画板)可交给 `lark-whiteboard` 等 skill 继续操作 |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 精确 block 级更新
|
||||
|
||||
1. **获取文档内容和 block ID**:
|
||||
```bash
|
||||
lark-cli docs +fetch --doc "<doc_id>" --detail with-ids
|
||||
```
|
||||
|
||||
2. **定位目标 block**:从返回的 XML 中找到要修改的 block 及其 `id` 属性
|
||||
|
||||
3. **执行更新**:
|
||||
```bash
|
||||
# 替换特定 block
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_replace \
|
||||
--block-id "blkcnXXXX" --content "<p>新内容</p>"
|
||||
|
||||
# 在某 block 后插入
|
||||
lark-cli docs +update --doc "<doc_id>" --command block_insert_after \
|
||||
--block-id "blkcnXXXX" --content "<h2>追加的章节</h2>"
|
||||
```
|
||||
|
||||
### 简单文本替换
|
||||
|
||||
不需要 block ID,直接匹配替换:
|
||||
|
||||
```bash
|
||||
lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
||||
--pattern "v1.0" --content "v2.0"
|
||||
```
|
||||
|
||||
## 画板处理
|
||||
|
||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||
|
||||
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- **精确操作优于全文覆盖**:使用 `block_replace`/`block_insert_after` 精确修改,避免 `overwrite` 全文覆盖
|
||||
- **str_replace 的匹配范围取决于格式**:
|
||||
- **XML 模式(默认)**:`--pattern` 只支持**行内**匹配,不支持跨行 / 跨 block。段落、整块或容器级(列表、表格、分栏、引用块等)改动请改用 `block_replace` 指定 block_id 重建。
|
||||
- **Markdown 模式**(`--doc-format markdown`):`--pattern` 同时支持**行内和跨行**匹配,还支持 `前缀...后缀` 省略号语法(用 `...` 串联首尾片段匹配一大段内容),可以一次替换多行文本;但仍建议优先按最小片段匹配,跨 block 容器级重写仍优先用 `block_replace`,避免副作用。
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
2. 用 `block_delete` 批量删除旧的 block
|
||||
3. 这样可以保留文档中其他不相关的内容(图片、评论等)
|
||||
- **表达形式**:插入或替换内容时,优先沿用用户要求和已有文档风格;需要结构化表达时可参考 [`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)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [`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-design-philosophy.md`](lark-doc-design-philosophy.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) — 创建文档
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
| Skill | 核心职责 | 约束 |
|
||||
|-------------------|-----------------------------------------------------------|---------------------------------|
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单图表或复杂空白画板 | 简单图可由主 Agent 直接写入;复杂图再隔离到 SubAgent |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板适用规则
|
||||
@@ -29,11 +29,9 @@
|
||||
> [!IMPORTANT]
|
||||
> ⚠️ **分别对每个图表进行决策**
|
||||
|
||||
如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B
|
||||
中的方式插入这个图表。在需要插入思维导图、时序图、类图、饼图、甘特图的时候可以插入 mermaid 块,在需要插入其他类型图表时启动
|
||||
SubAgent 插入 SVG。
|
||||
如果有多个位置需要插入图表,你需要根据每个图表的内容**分别决定**采用步骤 2A 还是 2B。思维导图、时序图、类图、饼图、甘特图可插入 mermaid 块;其他类型图表使用 SVG,简单图由主 Agent 直接写入,复杂图再启动 SubAgent。
|
||||
|
||||
建议优先使用 SVG 插入图表,除非其属于思维导图、时序图、类图、饼图、甘特图这类可以直接使用 mermaid 语法描述,且不适宜用 SVG 绘制的图表
|
||||
简单 Mermaid / SVG 图可由主 Agent 直接写入本地 XML;需要专门视觉设计、信息密度较高或容易布局翻车的 SVG,再启动 SubAgent 产出完整片段。
|
||||
|
||||
### 步骤 2A: 使用 mermaid 插入图表
|
||||
|
||||
@@ -44,9 +42,9 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
### 步骤 2B: SubAgent 使用 SVG 插入图表
|
||||
### 步骤 2B: 使用 SVG 插入图表
|
||||
|
||||
主 Agent 启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
简单 SVG 可由主 Agent 直接写入草稿;复杂 SVG 则启动 SubAgent,让它用 `docs +create` / `docs +update` 插入:
|
||||
|
||||
```xml
|
||||
|
||||
@@ -56,7 +54,7 @@ SubAgent 插入 SVG。
|
||||
</whiteboard>
|
||||
```
|
||||
|
||||
Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
复杂 SVG SubAgent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Workflow] 章节指南:
|
||||
|
||||
- doc token、插入位置(标题 / block_id / command)
|
||||
- 图表目标、受众、源段落或数据
|
||||
|
||||
@@ -41,7 +41,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
文档中可嵌入外部资源块(属于容器标签的特殊形式),需要额外语法创建:
|
||||
|
||||
- `<img>` — `<img href="https://..."/>` 上传网络图片
|
||||
- `<whiteboard>` — 简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;复杂图使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<whiteboard>` — 简单 Mermaid / SVG 图可由主 Agent 直接写入;复杂 SVG 可启动 SubAgent 产出完整 `<whiteboard type="svg">完整自包含 SVG</whiteboard>`;特别复杂或已有画板更新,使用 `<whiteboard type="blank"></whiteboard>` 先创建空白画板,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 调用 `lark-whiteboard` 写入;
|
||||
- `<sheet>` — `<sheet type="blank"></sheet>` 空白;`<sheet sheet-id="SID" token="TOKEN"></sheet>` 复制已有
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 从零创作工作流
|
||||
|
||||
用户提供主题、需求或简要说明,需要生成一份新的飞书文档时,遵循本工作流。
|
||||
|
||||
## 核心方法论 — Code-Act Loop
|
||||
|
||||
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
||||
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三)
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
循环在文档达到质量标准且满足用户需求时结束。不要试图一次性产出完美内容——迭代打磨效果更好。根据用户实际需求灵活决定文档结构和版块,而不是套用固定模板。
|
||||
|
||||
|
||||
## 典型 Code-Act Loop 流程
|
||||
|
||||
### 步骤一:规划与撰写(单 Agent 串行)
|
||||
|
||||
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
|
||||
|
||||
1. 分析用户需求:受众、目的、范围
|
||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||
3. `docs +create` 创建并撰写:
|
||||
- **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
|
||||
- **长文档**:先建骨架(标题 + 各级标题),再由主 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 下,用完自行清理
|
||||
|
||||
### 步骤二:整合审查与画板识别(串行)
|
||||
|
||||
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
|
||||
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `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` 插入
|
||||
|
||||
### 步骤四:专项校验
|
||||
|
||||
9. **字数门禁**:如果用户给出任何明确字数要求(如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现目标区间、`word_count` 和达标结论
|
||||
10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现
|
||||
@@ -1,68 +0,0 @@
|
||||
# 飞书文档写作原则
|
||||
|
||||
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。
|
||||
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
|
||||
|
||||
## 一、用户明确要求优先
|
||||
|
||||
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
|
||||
|
||||
## 二、默认写连贯段落
|
||||
|
||||
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
|
||||
|
||||
| 内容 | 用什么 | ❌ 别 |
|
||||
|---|---|---|
|
||||
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
|
||||
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
|
||||
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
|
||||
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
|
||||
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
|
||||
| 纯短并列项(无描述,如材料清单) | 列表 | — |
|
||||
| 章节(内容成块、需在目录导航) | 标题层级 | — |
|
||||
|
||||
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
|
||||
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
|
||||
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(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) 的「美化系统」。
|
||||
|
||||
| 语义 | 背景色 | 文字色 |
|
||||
|-|-|-|
|
||||
| 信息、说明 | `light-blue` | `blue` |
|
||||
| 成功、推荐 | `light-green` | `green` |
|
||||
| 警告 / 错误 / 风险 | `light-red` | `red` |
|
||||
| 注意、待确认 | `light-yellow` | `yellow` |
|
||||
| 中性、辅助 | `light-gray` | — |
|
||||
|
||||
## 六、写完自检
|
||||
|
||||
交付前快速回看:
|
||||
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
|
||||
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
|
||||
- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
|
||||
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。
|
||||
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
|
||||
@@ -1,48 +0,0 @@
|
||||
# 改写增强工作流
|
||||
|
||||
用户提供已有文档链接或 token,需要改写、润色、补充或重排版时,遵循本工作流。
|
||||
|
||||
## 核心方法论 — Code-Act Loop
|
||||
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二)
|
||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||
|
||||
## 核心原则:精准手术优于全量覆盖
|
||||
1. **精准手术**:只改用户指定的 block,不改其他 block。
|
||||
2. **全量覆盖**:如果用户明确要改整篇,才用 `overwrite` 命令。
|
||||
3. **保真约束**:改写时原文里的 `<cite type="user">`(@人)、`<cite type="doc">`(@文档)、`<img>`、`<source>`、`<whiteboard>`、`<sheet>`、`<bitable>`、`<synced_reference>` 等行内组件和资源块一律原样保留(含所有 token / user-id / doc-id 属性),不许替换成纯文本姓名、链接或占位符。
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 步骤一:分析与画板识别(串行)
|
||||
|
||||
1. **选择读取范围**(节省上下文的关键):
|
||||
- 用户只改某一节 / 文档较大 → 先 `docs +fetch --scope outline --max-depth 2` 拿目录,再 `docs +fetch --scope section --start-block-id <目标标题id> --detail with-ids` 精读该节(`section` 会自动展开到下一个同级/更高级标题前,不用手动算结束 block id)
|
||||
- 需要精确跨节区间 → `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`(读取范围)」小节
|
||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||
|
||||
### 步骤二:定向改写(单 Agent 串行)
|
||||
|
||||
5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
|
||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`)
|
||||
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
|
||||
|
||||
### 步骤三:验证(串行)
|
||||
|
||||
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
||||
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
|
||||
|
||||
### 步骤四:专项校验(按需执行)
|
||||
|
||||
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||
|
||||
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
|
||||
@@ -15,10 +15,9 @@
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `summary.aborted` | 命中终止性错误并停止后续批处理时为 `true` |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error` / `hint` / `phase` / `error_class` / `code` / `subtype` / `retryable`) |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `already_deleted` / `failed` / `delete_failed`。
|
||||
`items[].action` 取值:`uploaded` / `overwritten` / `skipped` / `folder_created` / `deleted_remote` / `failed` / `delete_failed`。
|
||||
|
||||
> 本地目录(包括空目录)会被镜像到 Drive;新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token,不会重复 `create_folder`,也不会出现在 `items[]` 里。
|
||||
|
||||
@@ -96,7 +95,6 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
|
||||
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
|
||||
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**(stderr 上有提示),命令以非零状态退出,远端不会被破坏。
|
||||
- 删除阶段如果服务端返回 `1061007 file has been delete`,说明目标远端文件在本次 DELETE 前已经不存在;这已经满足 `--delete-remote` 的目标状态,输出会记为 `action: "already_deleted"`,不计入 `summary.failed`,也不计入 `summary.deleted_remote`。
|
||||
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
|
||||
- 不传 `--delete-remote` → `summary.deleted_remote` 永远是 0;命令对远端"多余"文件视而不见。
|
||||
- 在线文档(docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
|
||||
@@ -112,46 +110,22 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"deleted_remote": 0,
|
||||
"aborted": false
|
||||
"deleted_remote": 0
|
||||
},
|
||||
"items": [
|
||||
{"rel_path": "...", "file_token": "...", "action": "folder_created"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "uploaded", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "overwritten", "version": "...", "size_bytes": 0},
|
||||
{"rel_path": "...", "file_token": "...", "action": "skipped", "size_bytes": 0},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "...", "hint": "...", "phase": "upload", "error_class": "...", "code": 0, "subtype": "...", "retryable": false},
|
||||
{"rel_path": "...", "action": "failed", "size_bytes": 0, "error": "..."},
|
||||
{"rel_path": "...", "file_token": "...", "action": "deleted_remote"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "already_deleted"},
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "...", "hint": "...", "phase": "delete", "error_class": "...", "code": 0, "subtype": "...", "retryable": false}
|
||||
{"rel_path": "...", "file_token": "...", "action": "delete_failed", "error": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。
|
||||
|
||||
## 失败处理与 agent 行为
|
||||
|
||||
`+push` 的失败项带结构化字段,agent 必须优先读 `items[].error_class` / `phase` / `code`,不要只看自然语言 `error` 文本。`summary.aborted=true` 表示命令已经遇到终止性错误并停止后续批处理;这时**不要原样重试**,先修复根因。
|
||||
|
||||
常见终止性错误:
|
||||
|
||||
| `error_class` | 常见 `code` | 含义 | Agent 应对 |
|
||||
|---|---:|---|---|
|
||||
| `app_scope_missing` | `99991672` | 应用身份缺少 Drive / 文件夹相关 scope | 停止重试,引导开通错误里列出的应用身份权限,例如 `space:folder:create` 或 `drive:drive` |
|
||||
| `user_scope_missing` | `99991679` | 用户身份缺少授权 | 停止重试,走 `lark-cli auth login --scope ...` 补错误里列出的 scope |
|
||||
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试,检查目标文件夹权限、身份类型(user / bot)和资源可见性 |
|
||||
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
|
||||
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
|
||||
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
|
||||
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |
|
||||
|
||||
非终止但需要解释的状态:
|
||||
|
||||
- `file_size_limit` / `1061043`:文件超过 Drive 上传限制。不要继续尝试同一文件;改拆分或换存储方式。
|
||||
- `upload_size_mismatch` / `1062009`:本地文件在上传过程中发生变化,或声明大小与实际读取大小不一致。重新扫描本地文件后再 push。
|
||||
- `remote_not_found` / `1061007`:一般表示远端文件已不存在。删除阶段的 `1061007` 会被视为 `already_deleted` 成功项;其他阶段需重新列表确认远端状态。
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-markdown
|
||||
version: 1.2.2
|
||||
version: 1.2.1
|
||||
description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -25,8 +25,7 @@ metadata:
|
||||
- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history`
|
||||
- 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx`
|
||||
- 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间(云盘/云存储)操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md)
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`quota_exceeded`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate_limit`、`server_error` 或临时网络错误才做有限退避重试。
|
||||
- `markdown +create` 的目标参数不要猜:Drive 文件夹用 `--folder-token`,Wiki 节点用 `--wiki-token`。如果用户给的是 URL,可以直接传完整 URL;CLI 会归一成 token。不要把 doc/sheet/wiki URL 放进 `--folder-token` 试错。
|
||||
- `markdown +create` / `+overwrite` 命中 `missing scope`、`permission denied`、`not found`、`version limit` 时,默认停止重试并按报错 hint 处理;只有 `rate limit` 或临时网络错误才做有限重试。
|
||||
|
||||
## 核心边界
|
||||
|
||||
|
||||
@@ -32,21 +32,11 @@ lark-cli markdown +create \
|
||||
--folder-token fldcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定文件夹(可直接传 Drive folder URL)
|
||||
lark-cli markdown +create \
|
||||
--folder-token "https://feishu.cn/drive/folder/fldcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点
|
||||
lark-cli markdown +create \
|
||||
--wiki-token wikcn_xxx \
|
||||
--file ./README.md
|
||||
|
||||
# 创建到指定 wiki 节点(可直接传 wiki URL)
|
||||
lark-cli markdown +create \
|
||||
--wiki-token "https://feishu.cn/wiki/wikcn_xxx" \
|
||||
--file ./README.md
|
||||
|
||||
# 预览底层请求
|
||||
lark-cli markdown +create \
|
||||
--name README.md \
|
||||
@@ -58,8 +48,8 @@ lark-cli markdown +create \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token 或 Drive folder URL;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token 或 wiki URL;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--folder-token` | 否 | 目标 Drive 文件夹 token;与 `--wiki-token` 互斥;省略时创建到根目录 |
|
||||
| `--wiki-token` | 否 | 目标 wiki 节点 token;与 `--folder-token` 互斥;传入后自动映射为 `parent_type=wiki` |
|
||||
| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 |
|
||||
| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) |
|
||||
| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 |
|
||||
@@ -68,8 +58,6 @@ lark-cli markdown +create \
|
||||
|
||||
- `--content` 与 `--file` 必须二选一
|
||||
- `--folder-token` 与 `--wiki-token` 互斥
|
||||
- `--folder-token` 只能是 Drive 文件夹;不要传 wiki/doc/sheet/base/file token 或 URL
|
||||
- `--wiki-token` 只能是 Wiki 节点;如果只有 docx/sheet/base 等文档 URL,先用 `lark-cli wiki +node-get --node-token <url>` 解析出 `node_token`
|
||||
- `--name` 必须带 `.md` 后缀
|
||||
- `--file` 指向的本地文件名也必须带 `.md` 后缀
|
||||
- 传 `--wiki-token` 时,返回值中不会附带 `/file/<token>` URL,因为 wiki 承载文件没有稳定的独立 file URL
|
||||
@@ -100,14 +88,6 @@ lark-cli markdown +create \
|
||||
>
|
||||
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。
|
||||
|
||||
## 失败处理
|
||||
|
||||
- `not_found` / `1061044`:父目录或 wiki 节点不存在,或 token 类型放错参数。修正 `--folder-token` / `--wiki-token` 后再试,不要重复提交同一参数。
|
||||
- `quota_exceeded` / `1061101`:目标存储空间配额已满。释放空间、换父目录/节点或请管理员扩容后再试。
|
||||
- `permission_denied` / `missing_scope`:区分身份处理。`--as user` 看用户授权和目标 ACL;`--as bot` 看应用 scope 与目标目录/节点 ACL。
|
||||
- `rate_limit`:停止立即重试,使用退避。
|
||||
- `server_error` / `233523001`:可以稍后有限重试;若重复出现,保留 `log_id` / request id 给服务端排查。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-markdown](../SKILL.md) — Markdown 域总览
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-wiki
|
||||
version: 1.0.2
|
||||
version: 1.0.1
|
||||
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:上传文件到知识库节点下(走 lark-drive)、编辑文档/表格/Base 内容(走 lark-doc / lark-sheets / lark-base)。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -34,8 +34,6 @@ metadata:
|
||||
- 用户明确选定后再执行 `lark-cli wiki +delete-space --space-id <ID> --yes`(高风险写操作,必须显式 `--yes`)。
|
||||
- 反例:不要把 wiki URL / 名称直接当 `--space-id`(如 `--space-id "https://.../wiki/<wiki_token>"`);务必先用 `wiki spaces get_node` 解析出 `data.node.space_id` 再传。
|
||||
- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。
|
||||
- 用户要列出 Wiki 节点:先用 `wiki +space-list --as user` 拿数字 `space_id`,再用 `wiki +node-list --space-id <space_id>`。不要把 wiki URL、node token、doc token、名称直接当 `--space-id`。钻子节点时 `--parent-node-token` 必须是 wiki node token;如果用户给的是 docx/sheet/base URL,先用 `wiki +node-get --node-token <url>` 解析出 `node_token`。
|
||||
- `wiki +node-list` 命中 `invalid_parameters`、`not_found`、`permission_denied` 时,不要重复调用同一参数;按 hint 修 `space_id` / `parent_node_token` / 权限。只有 `rate_limit` 才做退避重试。
|
||||
- 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门 / 应用”四类之一,再决定 `--member-type`,不要先调 `wiki +member-add` 再根据报错反推类型。
|
||||
- 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki +member-add --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。
|
||||
- 用户说“用户 / 群 / 应用 + 添加成员”:先解析对应 ID,再执行 `wiki +member-add`。
|
||||
|
||||
@@ -11,9 +11,6 @@ lark-cli wiki +node-list --space-id <SPACE_ID>
|
||||
# Drill into a sub-directory (still single page by default)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token <NODE_TOKEN>
|
||||
|
||||
# Drill with a wiki URL (CLI normalizes /wiki/<token> to node_token)
|
||||
lark-cli wiki +node-list --space-id <SPACE_ID> --parent-node-token "https://feishu.cn/wiki/wikcn_xxx"
|
||||
|
||||
# Personal document library (user identity only)
|
||||
lark-cli wiki +node-list --space-id my_library --as user
|
||||
|
||||
@@ -34,8 +31,8 @@ lark-cli wiki +node-list --space-id <SPACE_ID> --format pretty
|
||||
|
||||
| Flag | Type | Required | Default | Description |
|
||||
|------|------|----------|---------|-------------|
|
||||
| `--space-id` | string | **Yes** | — | Numeric wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent wiki node token, or a `/wiki/<token>` URL; omit to list the space root |
|
||||
| `--space-id` | string | **Yes** | — | Wiki space ID. Use `my_library` for personal document library (user only) |
|
||||
| `--parent-node-token` | string | No | — | Parent node token; omit to list the space root |
|
||||
| `--page-size` | int | No | 50 | Page size, 1-50 |
|
||||
| `--page-token` | string | No | — | Page cursor; implies single-page fetch (no auto-pagination) |
|
||||
| `--page-all` | bool | No | `false` | Automatically paginate through all pages (capped by `--page-limit`) |
|
||||
@@ -85,10 +82,6 @@ lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikc
|
||||
## Notes
|
||||
|
||||
- `--space-id my_library` is a per-user alias and only valid with `--as user`. The shortcut will refuse `--as bot` with `my_library` upfront.
|
||||
- `--space-id` is a numeric wiki `space_id`. Do not pass a wiki URL, wiki node token, document token, or title. Use `lark-cli wiki +space-list --as user` to discover it.
|
||||
- `--parent-node-token` must resolve to a wiki node token. If you have a docx/sheet/base/file URL, first run `lark-cli wiki +node-get --node-token <url>` and use the returned `node_token`.
|
||||
- Treat `invalid_parameters` (`space_id is not int`, `invalid page_token`), `not_found` (`node not found by parent node token`), and `permission_denied` as terminal for the current arguments. Fix the argument or permission before retrying.
|
||||
- For `rate_limit`, stop immediate retries and retry later with exponential backoff or a smaller `--page-limit`.
|
||||
|
||||
## Required Scope
|
||||
|
||||
|
||||
Reference in New Issue
Block a user