mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
Compare commits
3 Commits
feat/lark-
...
sun/lark-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d20c91588 | ||
|
|
46a1921720 | ||
|
|
a8829c2caf |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,9 +27,6 @@ Thumbs.db
|
||||
# Go
|
||||
docs/ref
|
||||
docs/
|
||||
!tests/cli_e2e/docs/
|
||||
!tests/cli_e2e/docs/*.go
|
||||
!tests/cli_e2e/docs/*.md
|
||||
vendor/
|
||||
|
||||
|
||||
|
||||
@@ -20,28 +20,13 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
|
||||
cmd := NewCmdApi(f, runF)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTestRootCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lark-cli",
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -69,7 +54,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -92,7 +77,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
||||
@@ -113,7 +98,7 @@ func TestApiCmd_BotMode(t *testing.T) {
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -140,7 +125,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET"}) // missing path
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -153,7 +138,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -166,7 +151,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
fn := cmd.ValidArgsFunction
|
||||
|
||||
tests := []struct {
|
||||
@@ -232,7 +217,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
flag := cmd.Flags().Lookup("as")
|
||||
if flag == nil {
|
||||
t.Fatal("expected --as flag to be registered")
|
||||
@@ -251,7 +236,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -270,7 +255,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -287,7 +272,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return apiRun(opts)
|
||||
})
|
||||
@@ -312,7 +297,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -343,7 +328,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -383,7 +368,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||
err := cmd.Execute()
|
||||
// Should return an error
|
||||
@@ -424,7 +409,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -463,7 +448,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -498,7 +483,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -564,8 +549,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -615,8 +600,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
@@ -671,8 +656,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
root := newTestRootCmd()
|
||||
root.AddCommand(newTestApiCmd(f, nil))
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
root.AddCommand(NewCmdApi(f, nil))
|
||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
@@ -736,7 +721,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -756,7 +741,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -775,7 +760,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
||||
@@ -806,7 +791,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -827,7 +812,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
||||
@@ -845,7 +830,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
||||
@@ -874,7 +859,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -895,7 +880,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -914,7 +899,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
@@ -932,7 +917,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
||||
@@ -949,7 +934,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
||||
@@ -966,7 +951,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
return apiRun(opts)
|
||||
})
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
||||
@@ -989,7 +974,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
@@ -1030,7 +1015,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
@@ -1056,7 +1041,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
||||
})
|
||||
|
||||
var gotOpts *APIOptions
|
||||
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -86,13 +86,10 @@ func symArrow() string {
|
||||
|
||||
// UpdateOptions holds inputs for the update command.
|
||||
type UpdateOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
SkillsLayout string
|
||||
FlatSkills string
|
||||
FlatSet bool
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
Force bool
|
||||
Check bool
|
||||
}
|
||||
|
||||
// NewCmdUpdate creates the update command.
|
||||
@@ -111,7 +108,6 @@ Detects the installation method automatically:
|
||||
Use --json for structured output (for AI agents and scripts).
|
||||
Use --check to only check for updates without installing.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.FlatSet = cmd.Flags().Changed("flat-skills")
|
||||
return updateRun(opts)
|
||||
},
|
||||
}
|
||||
@@ -119,8 +115,6 @@ Use --check to only check for updates without installing.`,
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
|
||||
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
|
||||
cmd.Flags().StringVar(&opts.SkillsLayout, "skills-layout", "", "skills layout: separate or hybrid")
|
||||
cmd.Flags().StringVar(&opts.FlatSkills, "flat-skills", "", "comma-separated skills kept as top-level skills when the effective layout is hybrid")
|
||||
cmdutil.SetRisk(cmd, "high-risk-write")
|
||||
|
||||
return cmd
|
||||
@@ -128,9 +122,6 @@ Use --check to only check for updates without installing.`,
|
||||
|
||||
func updateRun(opts *UpdateOptions) error {
|
||||
io := opts.Factory.IOStreams
|
||||
if err := validateSkillsLayoutOptions(opts); err != nil {
|
||||
return reportError(opts, io, "validation_error", err)
|
||||
}
|
||||
cur := currentVersion()
|
||||
updater := newUpdater()
|
||||
|
||||
@@ -156,7 +147,7 @@ func updateRun(opts *UpdateOptions) error {
|
||||
if !opts.Force && !update.IsNewer(latest, cur) {
|
||||
var skillsResult *skillscheck.SyncResult
|
||||
if !opts.Check {
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
skillsResult = runSkillsAndState(updater, io, cur, opts.Force)
|
||||
}
|
||||
return reportAlreadyUpToDate(opts, io, cur, latest, skillsResult, opts.Check)
|
||||
}
|
||||
@@ -217,7 +208,7 @@ func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest s
|
||||
}
|
||||
|
||||
func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
skillsResult := runSkillsAndState(updater, io, cur, opts.Force)
|
||||
|
||||
reason := detect.ManualReason()
|
||||
if opts.JSON {
|
||||
@@ -296,7 +287,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
||||
return output.ErrBare(output.ExitAPI)
|
||||
}
|
||||
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force, opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
skillsResult := runSkillsAndState(updater, io, latest, opts.Force)
|
||||
|
||||
if opts.JSON {
|
||||
result := map[string]interface{}{
|
||||
@@ -333,20 +324,16 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
|
||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||
}
|
||||
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout, requestedFlat string, flatSet bool) *skillscheck.SyncResult {
|
||||
layout, flat := resolveSkillsSyncOptions(requestedLayout, requestedFlat, flatSet)
|
||||
layoutExplicit := strings.TrimSpace(requestedLayout) != ""
|
||||
if !force && !layoutExplicit && !flatSet {
|
||||
if existing, existingLayout, ok := skillscheck.ReadSyncedVersionAndLayout(); ok && existingLayout != "" && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool) *skillscheck.SyncResult {
|
||||
if !force {
|
||||
if existing, ok := skillscheck.ReadSyncedVersion(); ok && normalizeVersion(existing) == normalizeVersion(stateVersion) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result := syncSkills(skillscheck.SyncOptions{
|
||||
Version: stateVersion,
|
||||
Layout: layout,
|
||||
FlatSkills: flat,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
Version: stateVersion,
|
||||
Force: force,
|
||||
Runner: updater,
|
||||
})
|
||||
if result.Err != nil && strings.Contains(result.Err.Error(), "state not written") {
|
||||
fmt.Fprintf(io.ErrOut, "warning: %v\n", result.Err)
|
||||
@@ -354,47 +341,6 @@ func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, state
|
||||
return result
|
||||
}
|
||||
|
||||
func validateSkillsLayoutOptions(opts *UpdateOptions) errs.TypedError {
|
||||
if _, ok := skillscheck.NormalizeLayout(opts.SkillsLayout); !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--skills-layout must be one of separate or hybrid").WithParam("--skills-layout")
|
||||
}
|
||||
layout, flat := resolveSkillsSyncOptions(opts.SkillsLayout, opts.FlatSkills, opts.FlatSet)
|
||||
if layout != skillscheck.LayoutHybrid {
|
||||
return nil
|
||||
}
|
||||
for _, skill := range flat {
|
||||
if skill == "lark-shared" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "lark-shared cannot be selected by --flat-skills; it is managed automatically for lark-suite compatibility").WithParam("--flat-skills")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveSkillsSyncOptions(requestedLayout, requestedFlat string, flatSet bool) (string, []string) {
|
||||
state, readable, err := skillscheck.ReadState()
|
||||
if err != nil {
|
||||
readable = false
|
||||
state = nil
|
||||
}
|
||||
|
||||
layout := skillscheck.LayoutSeparate
|
||||
if strings.TrimSpace(requestedLayout) != "" {
|
||||
layout, _ = skillscheck.NormalizeLayout(requestedLayout)
|
||||
} else if readable && state != nil {
|
||||
if stateLayout, ok := skillscheck.NormalizeLayout(state.Layout); ok && state.Layout != "" {
|
||||
layout = stateLayout
|
||||
}
|
||||
}
|
||||
|
||||
if flatSet {
|
||||
return layout, skillscheck.ParseFlatSkills(requestedFlat)
|
||||
}
|
||||
if readable && state != nil {
|
||||
return layout, state.FlatSkills
|
||||
}
|
||||
return layout, []string{}
|
||||
}
|
||||
|
||||
// reportAlreadyUpToDate emits the JSON / pretty output for the
|
||||
// already-up-to-date branch, including any skills_action / skills_warning
|
||||
// fields derived from skillsResult. When check is true, this is the pure
|
||||
@@ -441,12 +387,6 @@ func applySkillsStatus(env map[string]interface{}, target string) {
|
||||
if len(state.SkippedDeletedSkills) > 0 {
|
||||
status["skipped_deleted"] = state.SkippedDeletedSkills
|
||||
}
|
||||
if state.Layout != "" {
|
||||
status["layout"] = state.Layout
|
||||
}
|
||||
if len(state.FlatSkills) > 0 {
|
||||
status["flat_skills"] = state.FlatSkills
|
||||
}
|
||||
env["skills_status"] = status
|
||||
}
|
||||
|
||||
@@ -457,7 +397,6 @@ func applySkillsResult(env map[string]interface{}, r *skillscheck.SyncResult) {
|
||||
case r.Err != nil:
|
||||
env["skills_action"] = "failed"
|
||||
env["skills_warning"] = fmt.Sprintf("skills update failed: %s", r.Err)
|
||||
env["skills_hint"] = skillsFailureHint()
|
||||
env["skills_summary"] = skillsSummary(r)
|
||||
default:
|
||||
env["skills_action"] = "synced"
|
||||
@@ -471,17 +410,10 @@ func skillsSummary(r *skillscheck.SyncResult) map[string]interface{} {
|
||||
"updated": len(r.Updated),
|
||||
"added": len(r.Added),
|
||||
"skipped_deleted": len(r.SkippedDeleted),
|
||||
"layout": r.Layout,
|
||||
}
|
||||
if len(r.Failed) > 0 {
|
||||
summary["failed"] = r.Failed
|
||||
}
|
||||
if len(r.Collected) > 0 {
|
||||
summary["collected"] = r.Collected
|
||||
}
|
||||
if len(r.Flat) > 0 {
|
||||
summary["flat"] = r.Flat
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
@@ -493,17 +425,13 @@ func emitSkillsTextHints(io *cmdutil.IOStreams, r *skillscheck.SyncResult) {
|
||||
if len(r.Failed) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " Failed skills: %s\n", strings.Join(r.Failed, ", "))
|
||||
}
|
||||
fmt.Fprintf(io.ErrOut, " %s\n", skillsFailureHint())
|
||||
fmt.Fprintf(io.ErrOut, " To retry all official skills: lark-cli update --force\n")
|
||||
case r.Force:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills (%s layout)\n", symOK(), len(r.Official), r.Layout)
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: restored all %d official skills\n", symOK(), len(r.Official))
|
||||
default:
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally (%s layout)\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted), r.Layout)
|
||||
fmt.Fprintf(io.ErrOut, "%s Skills updated: %d official, %d updated, %d added, %d skipped because deleted locally\n", symOK(), len(r.Official), len(r.Updated), len(r.Added), len(r.SkippedDeleted))
|
||||
if len(r.SkippedDeleted) > 0 {
|
||||
fmt.Fprintf(io.ErrOut, " To restore all official skills: lark-cli update --force\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillsFailureHint() string {
|
||||
return "Retry: lark-cli update --force. To switch to separate top-level skills: lark-cli update --skills-layout separate (this saves layout=separate and clears saved flat_skills)."
|
||||
}
|
||||
|
||||
@@ -996,7 +996,7 @@ func newTestIO() *cmdutil.IOStreams {
|
||||
|
||||
func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21", Layout: skillscheck.LayoutSeparate}); err != nil {
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
@@ -1006,7 +1006,7 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
return &selfupdate.NpmResult{}
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got != nil {
|
||||
t.Errorf("runSkillsAndState() = %+v, want nil for dedup hit", got)
|
||||
}
|
||||
@@ -1015,27 +1015,6 @@ func TestRunSkillsAndState_DedupHit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_MissingLayoutDoesNotDedup(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want successful sync when state lacks layout", got)
|
||||
}
|
||||
if !called {
|
||||
t.Error("SkillsCommandOverride not called, want resync when state lacks layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{Version: "1.0.21"}); err != nil {
|
||||
@@ -1048,7 +1027,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
return successfulSkillsCommand()(args...)
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", true)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState(force=true) = %+v, want successful result", got)
|
||||
}
|
||||
@@ -1060,7 +1039,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
}
|
||||
@@ -1071,12 +1050,6 @@ func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
if state.Version != "1.0.21" {
|
||||
t.Errorf("state.Version = %q, want \"1.0.21\"", state.Version)
|
||||
}
|
||||
if state.Layout != skillscheck.LayoutSeparate {
|
||||
t.Errorf("state.Layout = %q, want %q", state.Layout, skillscheck.LayoutSeparate)
|
||||
}
|
||||
if len(state.FlatSkills) != 0 {
|
||||
t.Errorf("state.FlatSkills = %#v, want empty", state.FlatSkills)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
@@ -1091,7 +1064,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
return r
|
||||
},
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with non-nil Err", got)
|
||||
}
|
||||
@@ -1104,57 +1077,6 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillsSyncOptions_UsesStateAsFallback(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
if err := skillscheck.WriteState(skillscheck.SkillsState{
|
||||
Version: "1.0.21",
|
||||
Layout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: []string{"lark-doc"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
layout, flat := resolveSkillsSyncOptions("", "", false)
|
||||
if layout != skillscheck.LayoutHybrid {
|
||||
t.Fatalf("layout = %q, want %q", layout, skillscheck.LayoutHybrid)
|
||||
}
|
||||
if len(flat) != 1 || flat[0] != "lark-doc" {
|
||||
t.Fatalf("flat = %#v, want [lark-doc]", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsRejectsSuiteMode(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{SkillsLayout: "suite"})
|
||||
if err == nil {
|
||||
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
|
||||
}
|
||||
if validation.Param != "--skills-layout" {
|
||||
t.Fatalf("validation.Param = %q, want --skills-layout", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSkillsLayoutOptionsRejectsSharedFlatInHybrid(t *testing.T) {
|
||||
err := validateSkillsLayoutOptions(&UpdateOptions{
|
||||
SkillsLayout: skillscheck.LayoutHybrid,
|
||||
FlatSkills: "lark-shared",
|
||||
FlatSet: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("validateSkillsLayoutOptions() err = nil, want validation error")
|
||||
}
|
||||
var validation *errs.ValidationError
|
||||
if !errors.As(err, &validation) {
|
||||
t.Fatalf("errors.As(err, *ValidationError) = false for %T", err)
|
||||
}
|
||||
if validation.Param != "--flat-skills" {
|
||||
t.Fatalf("validation.Param = %q, want --flat-skills", validation.Param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
got := selfupdate.Truncate(long, 2000)
|
||||
@@ -1435,7 +1357,7 @@ func TestRunSkillsAndState_StateWriteFailureWarns(t *testing.T) {
|
||||
t.Cleanup(func() { syncSkills = origSync })
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false, "", "", false)
|
||||
got := runSkillsAndState(&selfupdate.Updater{}, f.IOStreams, "1.0.21", false)
|
||||
if got == nil || got.Err == nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with write error", got)
|
||||
}
|
||||
|
||||
@@ -49,8 +49,6 @@ const (
|
||||
var (
|
||||
skillsIndexFetchTimeout = 10 * time.Second
|
||||
officialSkillsIndexURL = "https://open.feishu.cn/.well-known/skills/index.json"
|
||||
isolatedSkillsSourceURL = "https://open.feishu.cn/lark-cli/isolated-skills"
|
||||
isolatedSkillsFallback = "larksuite/cli/isolated-skills"
|
||||
)
|
||||
|
||||
// DetectResult holds installation detection results.
|
||||
@@ -244,14 +242,6 @@ func (u *Updater) InstallAllSkills() *NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) InstallSuiteSkill() *NpmResult {
|
||||
r := u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
if r.Err != nil {
|
||||
r = u.runSkillsInstall(isolatedSkillsFallback, []string{"lark-suite"})
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (u *Updater) runSkillsAdd(source string) *NpmResult {
|
||||
return u.runSkillsCommand("-y", "skills", "add", source, "-g", "-y")
|
||||
}
|
||||
|
||||
@@ -208,13 +208,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn -s lark-mail -g -y",
|
||||
},
|
||||
{
|
||||
name: "install isolated suite skill",
|
||||
run: func(u *Updater) *NpmResult {
|
||||
return u.runSkillsInstall(isolatedSkillsSourceURL, []string{"lark-suite"})
|
||||
},
|
||||
want: "-y skills add https://open.feishu.cn/lark-cli/isolated-skills -s lark-suite -g -y",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -245,34 +238,6 @@ func TestSkillsCommandsUseExpectedArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSuiteSkillFallsBackToIsolatedGitHubSource(t *testing.T) {
|
||||
called := []string{}
|
||||
u := &Updater{
|
||||
SkillsCommandOverride: func(args ...string) *NpmResult {
|
||||
called = append(called, strings.Join(args, " "))
|
||||
r := &NpmResult{}
|
||||
if strings.Contains(strings.Join(args, " "), isolatedSkillsSourceURL) {
|
||||
r.Err = fmt.Errorf("isolated source unavailable")
|
||||
}
|
||||
return r
|
||||
},
|
||||
}
|
||||
|
||||
result := u.InstallSuiteSkill()
|
||||
if result.Err != nil {
|
||||
t.Fatalf("InstallSuiteSkill() err = %v, want nil", result.Err)
|
||||
}
|
||||
if len(called) != 2 {
|
||||
t.Fatalf("calls = %#v, want primary and fallback", called)
|
||||
}
|
||||
if !strings.Contains(called[0], isolatedSkillsSourceURL) {
|
||||
t.Fatalf("primary call = %q, want isolated source", called[0])
|
||||
}
|
||||
if !strings.Contains(called[1], isolatedSkillsFallback) {
|
||||
t.Fatalf("fallback call = %q, want isolated GitHub fallback", called[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListOfficialSkillsIndexSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprint(w, `{"skills":[{"name":"lark-calendar"}]}`)
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillscheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
LayoutSeparate = "separate"
|
||||
LayoutHybrid = "hybrid"
|
||||
|
||||
suiteSkillName = "lark-suite"
|
||||
sharedSkillName = "lark-shared"
|
||||
suiteRoutesPlaceholder = "<!-- LARK_SUITE_ROUTES -->"
|
||||
)
|
||||
|
||||
type GlobalSkillInfo struct {
|
||||
Name string
|
||||
Path string
|
||||
}
|
||||
|
||||
func NormalizeLayout(layout string) (string, bool) {
|
||||
switch strings.TrimSpace(layout) {
|
||||
case "", LayoutSeparate:
|
||||
return LayoutSeparate, true
|
||||
case LayoutHybrid:
|
||||
return LayoutHybrid, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func ParseFlatSkills(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
name := strings.TrimSpace(part)
|
||||
if name != "" {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func ParseGlobalSkillInfosJSON(text string) []GlobalSkillInfo {
|
||||
infos, _ := parseGlobalSkillInfosJSON(text)
|
||||
return infos
|
||||
}
|
||||
|
||||
func parseGlobalSkillInfosJSON(text string) ([]GlobalSkillInfo, bool) {
|
||||
type globalSkill struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
var skills []globalSkill
|
||||
if err := json.Unmarshal([]byte(text), &skills); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
seen := map[string]GlobalSkillInfo{}
|
||||
for _, skill := range skills {
|
||||
name := strings.TrimSpace(skill.Name)
|
||||
path := strings.TrimSpace(skill.Path)
|
||||
if name == "" || path == "" || !skillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
seen[name] = GlobalSkillInfo{Name: name, Path: path}
|
||||
}
|
||||
|
||||
out := make([]GlobalSkillInfo, 0, len(seen))
|
||||
for _, info := range seen {
|
||||
out = append(out, info)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, true
|
||||
}
|
||||
|
||||
func installedSkillNamesFromInfos(infos []GlobalSkillInfo) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, info := range infos {
|
||||
seen[info.Name] = true
|
||||
if info.Name == suiteSkillName {
|
||||
for _, subskill := range listSuiteSubskills(info.Path) {
|
||||
seen[subskill] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func listSuiteSubskills(suitePath string) []string {
|
||||
entries, err := os.ReadDir(filepath.Join(suitePath, "references", "subskills"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(entry.Name())
|
||||
if name != "" && skillNamePattern.MatchString(name) {
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
|
||||
func normalOfficialSkills(skills []string) []string {
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(skills) {
|
||||
if skill != suiteSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func deletedOfficialSkills(official, local []string, previous *SkillsState, stateReadable, force bool, layout string) []string {
|
||||
if force || !stateReadable || previous == nil {
|
||||
return []string{}
|
||||
}
|
||||
officialSet := toSet(official)
|
||||
localSet := toSet(local)
|
||||
deleted := map[string]bool{}
|
||||
for _, skill := range previous.OfficialSkills {
|
||||
if !officialSet[skill] || localSet[skill] {
|
||||
continue
|
||||
}
|
||||
if layout != LayoutSeparate && skill == sharedSkillName {
|
||||
continue
|
||||
}
|
||||
deleted[skill] = true
|
||||
}
|
||||
return sortedKeys(deleted)
|
||||
}
|
||||
|
||||
func suiteEffectiveSkills(official []string, deleted map[string]bool) []string {
|
||||
out := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if !deleted[skill] {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(out)
|
||||
}
|
||||
|
||||
func resolveHybridSkillSets(layout string, requestedFlat, official []string, skippedDeleted []string) ([]string, []string, error) {
|
||||
if layout == LayoutSeparate {
|
||||
return []string{}, []string{}, nil
|
||||
}
|
||||
|
||||
officialSet := toSet(official)
|
||||
deletedSet := toSet(skippedDeleted)
|
||||
configuredFlat := map[string]bool{}
|
||||
effectiveFlat := map[string]bool{}
|
||||
for _, skill := range uniqueSorted(requestedFlat) {
|
||||
if skill == sharedSkillName {
|
||||
return nil, nil, fmt.Errorf("%s cannot be selected by --flat-skills", sharedSkillName)
|
||||
}
|
||||
if !officialSet[skill] {
|
||||
return nil, nil, fmt.Errorf("flat skill %q is not in official skills", skill)
|
||||
}
|
||||
configuredFlat[skill] = true
|
||||
if !deletedSet[skill] {
|
||||
effectiveFlat[skill] = true
|
||||
}
|
||||
}
|
||||
|
||||
collected := []string{}
|
||||
for _, skill := range normalOfficialSkills(official) {
|
||||
if skill == sharedSkillName {
|
||||
collected = append(collected, skill)
|
||||
continue
|
||||
}
|
||||
if deletedSet[skill] || effectiveFlat[skill] {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, skill)
|
||||
}
|
||||
return sortedKeys(configuredFlat), uniqueSortedWithFirst(collected, sharedSkillName), nil
|
||||
}
|
||||
|
||||
func uniqueSortedWithFirst(values []string, first string) []string {
|
||||
seen := toSet(values)
|
||||
if !seen[first] {
|
||||
return sortedKeys(seen)
|
||||
}
|
||||
delete(seen, first)
|
||||
return append([]string{first}, sortedKeys(seen)...)
|
||||
}
|
||||
|
||||
func assembleSuiteLayout(layout string, collected []string, keepSharedTopLevel bool, infos []GlobalSkillInfo) error {
|
||||
if layout == LayoutSeparate {
|
||||
return nil
|
||||
}
|
||||
|
||||
infoByName := map[string]GlobalSkillInfo{}
|
||||
for _, info := range infos {
|
||||
infoByName[info.Name] = info
|
||||
}
|
||||
suiteInfo, ok := infoByName[suiteSkillName]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s was not installed from isolated skills source", suiteSkillName)
|
||||
}
|
||||
|
||||
subskillsDir := filepath.Join(suiteInfo.Path, "references", "subskills")
|
||||
if err := os.RemoveAll(subskillsDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(subskillsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, skill := range collected {
|
||||
info, ok := infoByName[skill]
|
||||
if !ok {
|
||||
return fmt.Errorf("suite subskill %q was not installed", skill)
|
||||
}
|
||||
dst := filepath.Join(subskillsDir, skill)
|
||||
if keepSharedTopLevel && skill == sharedSkillName {
|
||||
if err := copyDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := moveDir(info.Path, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return renderSuiteRoutes(suiteInfo.Path, collected)
|
||||
}
|
||||
|
||||
func renderSuiteRoutes(suitePath string, collected []string) error {
|
||||
skillPath := filepath.Join(suitePath, "SKILL.md")
|
||||
data, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := normalizeSuiteTemplateText(string(data))
|
||||
routes := []string{}
|
||||
for _, skill := range collected {
|
||||
desc := skillDescription(filepath.Join(suitePath, "references", "subskills", skill, "SKILL.md"))
|
||||
if desc == "" {
|
||||
desc = skill
|
||||
}
|
||||
routes = append(routes, fmt.Sprintf("- %s: %s", skill, desc))
|
||||
}
|
||||
if !strings.Contains(text, suiteRoutesPlaceholder) {
|
||||
return fmt.Errorf("%s route placeholder not found", suiteSkillName)
|
||||
}
|
||||
text = strings.Replace(text, suiteRoutesPlaceholder, strings.Join(routes, "\n"), 1)
|
||||
return os.WriteFile(skillPath, []byte(text), 0o644)
|
||||
}
|
||||
|
||||
func normalizeSuiteTemplateText(text string) string {
|
||||
text = strings.ReplaceAll(text, "--collected-skills", "--flat-skills")
|
||||
oldShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
newShared := "`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。"
|
||||
return strings.ReplaceAll(text, oldShared, newShared)
|
||||
}
|
||||
|
||||
func skillDescription(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return ""
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "---" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "description:") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
|
||||
if value == ">" || value == "|" {
|
||||
return foldedYAMLScalar(lines[i+2:])
|
||||
}
|
||||
return strings.Trim(value, `"'`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func foldedYAMLScalar(lines []string) string {
|
||||
parts := []string{}
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "---" || !isIndentedYAMLLine(line) {
|
||||
break
|
||||
}
|
||||
parts = append(parts, trimmed)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func isIndentedYAMLLine(line string) bool {
|
||||
return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")
|
||||
}
|
||||
|
||||
func moveDir(src, dst string) error {
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := copyDir(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
|
||||
func copyDir(src, dst string) error {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("%s is not a directory", src)
|
||||
}
|
||||
if err := os.RemoveAll(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return filepath.WalkDir(src, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
return copyFile(path, target)
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
}
|
||||
@@ -23,12 +23,10 @@ var ErrUnreadableState = errors.New("skills state is unreadable")
|
||||
|
||||
type SkillsState struct {
|
||||
Version string `json:"version"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
OfficialSkills []string `json:"official_skills"`
|
||||
UpdatedSkills []string `json:"updated_skills"`
|
||||
AddedOfficialSkills []string `json:"added_official_skills"`
|
||||
SkippedDeletedSkills []string `json:"skipped_deleted_skills"`
|
||||
FlatSkills []string `json:"flat_skills"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -78,14 +76,6 @@ func ReadSyncedVersion() (string, bool) {
|
||||
return state.Version, true
|
||||
}
|
||||
|
||||
func ReadSyncedVersionAndLayout() (version string, layout string, ok bool) {
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable || state.Version == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return state.Version, state.Layout, true
|
||||
}
|
||||
|
||||
func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.OfficialSkills == nil {
|
||||
s.OfficialSkills = []string{}
|
||||
@@ -99,7 +89,4 @@ func (s *SkillsState) ensureNonNilSlices() {
|
||||
if s.SkippedDeletedSkills == nil {
|
||||
s.SkippedDeletedSkills = []string{}
|
||||
}
|
||||
if s.FlatSkills == nil {
|
||||
s.FlatSkills = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ var (
|
||||
|
||||
type SyncInput struct {
|
||||
Version string
|
||||
Layout string
|
||||
OfficialSkills []string
|
||||
LocalSkills []string
|
||||
PreviousState *SkillsState
|
||||
@@ -196,18 +195,7 @@ func parseOfficialSkillsList(lines []string) []string {
|
||||
}
|
||||
|
||||
func PlanSync(input SyncInput) SyncPlan {
|
||||
official := normalOfficialSkills(input.OfficialSkills)
|
||||
layout, _ := NormalizeLayout(input.Layout)
|
||||
skippedDeleted := deletedOfficialSkills(official, input.LocalSkills, input.PreviousState, input.StateReadable, input.Force, layout)
|
||||
if layout != LayoutSeparate {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: suiteEffectiveSkills(official, toSet(skippedDeleted)),
|
||||
Added: newlyOfficialSkills(official, input.PreviousState, input.StateReadable),
|
||||
SkippedDeleted: skippedDeleted,
|
||||
}
|
||||
}
|
||||
official := uniqueSorted(input.OfficialSkills)
|
||||
if input.Force {
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
@@ -241,12 +229,19 @@ func PlanSync(input SyncInput) SyncPlan {
|
||||
toUpdate := sortedKeys(updateSet)
|
||||
updateSet = toSet(toUpdate)
|
||||
|
||||
skipped := []string{}
|
||||
for _, skill := range official {
|
||||
if !updateSet[skill] {
|
||||
skipped = append(skipped, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return SyncPlan{
|
||||
Version: input.Version,
|
||||
OfficialSkills: official,
|
||||
ToUpdate: toUpdate,
|
||||
Added: uniqueSorted(newAddedOfficial),
|
||||
SkippedDeleted: skippedDeleted,
|
||||
SkippedDeleted: skipped,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,16 +252,13 @@ type SkillsRunner interface {
|
||||
ListGlobalSkills() *selfupdate.NpmResult
|
||||
InstallSkill(nameList []string) *selfupdate.NpmResult
|
||||
InstallAllSkills() *selfupdate.NpmResult
|
||||
InstallSuiteSkill() *selfupdate.NpmResult
|
||||
}
|
||||
|
||||
type SyncOptions struct {
|
||||
Version string
|
||||
Layout string
|
||||
FlatSkills []string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
Version string
|
||||
Force bool
|
||||
Runner SkillsRunner
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type SyncResult struct {
|
||||
@@ -279,9 +271,6 @@ type SyncResult struct {
|
||||
Err error
|
||||
Detail string
|
||||
Force bool
|
||||
Layout string
|
||||
Flat []string
|
||||
Collected []string
|
||||
}
|
||||
|
||||
func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
@@ -291,26 +280,16 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
if opts.Runner == nil {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("skills runner is nil")}
|
||||
}
|
||||
layout, ok := NormalizeLayout(opts.Layout)
|
||||
if !ok {
|
||||
return &SyncResult{Action: "failed", Err: fmt.Errorf("unsupported skills layout %q", opts.Layout)}
|
||||
}
|
||||
|
||||
// --- Step 1: List official skills ---
|
||||
official, reason, ok := listOfficialSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to discover official skills for %s layout: %s", layout, reason), reason)
|
||||
}
|
||||
return fallbackFullInstall(opts, reason, nil)
|
||||
}
|
||||
|
||||
// --- Step 2: List local (installed) skills ---
|
||||
local, ok := listLocalSkills(opts.Runner)
|
||||
if !ok {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to list local skills for %s layout", layout), "local skills list failed or parsed as empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "local skills list failed or parsed as empty", official)
|
||||
}
|
||||
|
||||
@@ -323,17 +302,12 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
|
||||
plan := PlanSync(SyncInput{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: official,
|
||||
LocalSkills: local,
|
||||
PreviousState: previous,
|
||||
StateReadable: readable,
|
||||
Force: opts.Force,
|
||||
})
|
||||
flat, collected, err := resolveHybridSkillSets(layout, opts.FlatSkills, plan.OfficialSkills, plan.SkippedDeleted)
|
||||
if err != nil {
|
||||
return &SyncResult{Action: "failed", Err: err, Official: plan.OfficialSkills, Force: opts.Force, Layout: layout}
|
||||
}
|
||||
|
||||
result := &SyncResult{
|
||||
Action: "synced",
|
||||
@@ -342,59 +316,25 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
Added: plan.Added,
|
||||
SkippedDeleted: plan.SkippedDeleted,
|
||||
Force: opts.Force,
|
||||
Layout: layout,
|
||||
Flat: flat,
|
||||
Collected: collected,
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) == 0 {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("no target skills to assemble %s layout", layout), "toUpdate skills empty")
|
||||
}
|
||||
return fallbackFullInstall(opts, "toUpdate skills empty fallback", official)
|
||||
}
|
||||
|
||||
if len(plan.ToUpdate) > 0 {
|
||||
installResult := opts.Runner.InstallSkill(plan.ToUpdate)
|
||||
if installResult == nil || installResult.Err != nil {
|
||||
if layout != LayoutSeparate {
|
||||
return failedSync(layout, opts.Force, fmt.Errorf("failed to install skills for %s layout: %s", layout, resultDetail(installResult)), resultDetail(installResult))
|
||||
}
|
||||
return fallbackFullInstall(opts, resultDetail(installResult), official)
|
||||
}
|
||||
}
|
||||
if layout != LayoutSeparate {
|
||||
installSuiteResult := opts.Runner.InstallSuiteSkill()
|
||||
if installSuiteResult == nil || installSuiteResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to install %s from isolated skills source: %s", suiteSkillName, resultDetail(installSuiteResult))
|
||||
result.Detail = resultDetail(installSuiteResult)
|
||||
return result
|
||||
}
|
||||
infosResult := opts.Runner.ListGlobalSkillsJSON()
|
||||
if infosResult == nil || infosResult.Err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to list installed skills for %s assembly: %s", suiteSkillName, resultDetail(infosResult))
|
||||
result.Detail = resultDetail(infosResult)
|
||||
return result
|
||||
}
|
||||
infos := ParseGlobalSkillInfosJSON(infosResult.Stdout.String())
|
||||
keepSharedTopLevel := layout == LayoutHybrid && len(flat) > 0
|
||||
if err := assembleSuiteLayout(layout, collected, keepSharedTopLevel, infos); err != nil {
|
||||
result.Action = "failed"
|
||||
result.Err = fmt.Errorf("failed to assemble %s layout: %w", layout, err)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: layout,
|
||||
OfficialSkills: plan.OfficialSkills,
|
||||
UpdatedSkills: plan.ToUpdate,
|
||||
AddedOfficialSkills: plan.Added,
|
||||
SkippedDeletedSkills: plan.SkippedDeleted,
|
||||
FlatSkills: stateFlatSkills(layout, flat),
|
||||
UpdatedAt: opts.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := WriteState(state); err != nil {
|
||||
@@ -406,16 +346,6 @@ func SyncSkills(opts SyncOptions) *SyncResult {
|
||||
return result
|
||||
}
|
||||
|
||||
func failedSync(layout string, force bool, err error, detail string) *SyncResult {
|
||||
return &SyncResult{
|
||||
Action: "failed",
|
||||
Err: err,
|
||||
Detail: detail,
|
||||
Force: force,
|
||||
Layout: layout,
|
||||
}
|
||||
}
|
||||
|
||||
func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
reasons := []string{}
|
||||
|
||||
@@ -453,9 +383,8 @@ func listOfficialSkills(runner SkillsRunner) ([]string, string, bool) {
|
||||
func listLocalSkills(runner SkillsRunner) ([]string, bool) {
|
||||
jsonResult := runner.ListGlobalSkillsJSON()
|
||||
if jsonResult != nil && jsonResult.Err == nil {
|
||||
infos, valid := parseGlobalSkillInfosJSON(jsonResult.Stdout.String())
|
||||
if valid {
|
||||
return installedSkillNamesFromInfos(infos), true
|
||||
if local := ParseGlobalSkillsJSON(jsonResult.Stdout.String()); len(local) > 0 {
|
||||
return local, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +411,6 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: empty result (reason: %s)", reason),
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
if installResult.Err != nil {
|
||||
@@ -491,13 +419,11 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
Err: fmt.Errorf("full skills install failed: %w (reason: %s)", installResult.Err, reason),
|
||||
Detail: reason + "\n" + resultDetail(installResult),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
state := SkillsState{
|
||||
Version: opts.Version,
|
||||
Layout: LayoutSeparate,
|
||||
OfficialSkills: official,
|
||||
UpdatedSkills: official,
|
||||
AddedOfficialSkills: official,
|
||||
@@ -513,7 +439,6 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason + "\nstate write failed: " + writeErr.Error(),
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,38 +450,9 @@ func fallbackFullInstall(opts SyncOptions, reason string, official []string) *Sy
|
||||
SkippedDeleted: []string{},
|
||||
Detail: reason,
|
||||
Force: opts.Force,
|
||||
Layout: LayoutSeparate,
|
||||
}
|
||||
}
|
||||
|
||||
func stateFlatSkills(layout string, requested []string) []string {
|
||||
if layout != LayoutHybrid {
|
||||
return []string{}
|
||||
}
|
||||
out := []string{}
|
||||
for _, skill := range uniqueSorted(requested) {
|
||||
if skill != sharedSkillName {
|
||||
out = append(out, skill)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newlyOfficialSkills(official []string, previous *SkillsState, stateReadable bool) []string {
|
||||
previousOfficial := []string{}
|
||||
if stateReadable && previous != nil {
|
||||
previousOfficial = previous.OfficialSkills
|
||||
}
|
||||
previousSet := toSet(previousOfficial)
|
||||
added := []string{}
|
||||
for _, skill := range official {
|
||||
if !previousSet[skill] {
|
||||
added = append(added, skill)
|
||||
}
|
||||
}
|
||||
return uniqueSorted(added)
|
||||
}
|
||||
|
||||
func resultDetail(result *selfupdate.NpmResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -217,10 +216,8 @@ type fakeSkillsRunner struct {
|
||||
globalErr error
|
||||
installErr error
|
||||
installAllErr error
|
||||
installSuiteErr error
|
||||
installed [][]string
|
||||
installedAll int
|
||||
installedSuite int
|
||||
listedIndex int
|
||||
listedOfficial int
|
||||
listedGlobalJSON int
|
||||
@@ -322,13 +319,6 @@ func (f *fakeSkillsRunner) InstallAllSkills() *selfupdate.NpmResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (f *fakeSkillsRunner) InstallSuiteSkill() *selfupdate.NpmResult {
|
||||
f.installedSuite++
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = f.installSuiteErr
|
||||
return r
|
||||
}
|
||||
|
||||
func TestSyncSkills_WritesStateAndDoesNotWriteStamp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -584,7 +574,7 @@ func TestSyncSkills_LocalListsFailureFallsBackToFullInstall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T) {
|
||||
func TestSyncSkills_ParseEmptyLocalListsFallBackToFullInstall(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
runner := &fakeSkillsRunner{
|
||||
@@ -595,15 +585,14 @@ func TestSyncSkills_EmptyGlobalJSONInstallsAllOfficialIncrementally(t *testing.T
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{Version: "1.0.33", Runner: runner, Now: time.Now})
|
||||
if result.Action != "synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want synced", result.Action)
|
||||
if result.Action != "fallback_synced" {
|
||||
t.Fatalf("SyncSkills() action = %q, want fallback_synced", result.Action)
|
||||
}
|
||||
if len(runner.installed) != 1 {
|
||||
t.Fatalf("installed = %#v, want one incremental install", runner.installed)
|
||||
if len(runner.installed) != 0 {
|
||||
t.Fatalf("installed = %#v, want no incremental installs", runner.installed)
|
||||
}
|
||||
assertStrings(t, runner.installed[0], []string{"lark-calendar", "lark-mail"})
|
||||
if runner.installedAll != 0 {
|
||||
t.Fatalf("installedAll = %d, want 0", runner.installedAll)
|
||||
if runner.installedAll != 1 {
|
||||
t.Fatalf("installedAll = %d, want 1", runner.installedAll)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,115 +697,6 @@ func TestSyncSkills_NilRunnerFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridAssemblesSuiteAndMovesCollectedSkills(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-calendar", "lark-doc", "lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-doc", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-calendar"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if runner.installedSuite != 1 {
|
||||
t.Fatalf("installedSuite = %d, want 1", runner.installedSuite)
|
||||
}
|
||||
assertStrings(t, result.Flat, []string{"lark-calendar"})
|
||||
assertStrings(t, result.Collected, []string{"lark-shared", "lark-doc"})
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-doc missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-suite"], "references", "subskills", "lark-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("suite lark-shared missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-calendar"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("flat lark-calendar missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-doc"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("collected lark-doc still exists at top level or unexpected err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); err != nil {
|
||||
t.Fatalf("lark-shared should stay top-level when flat set is non-empty: %v", err)
|
||||
}
|
||||
|
||||
state, readable, err := ReadState()
|
||||
if err != nil || !readable {
|
||||
t.Fatalf("ReadState() = (_, %v, %v), want readable", readable, err)
|
||||
}
|
||||
if state.Layout != LayoutHybrid {
|
||||
t.Fatalf("state.Layout = %q, want %q", state.Layout, LayoutHybrid)
|
||||
}
|
||||
assertStrings(t, state.FlatSkills, []string{"lark-calendar"})
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridWithNoFlatSkillsDoesNotKeepSharedTopLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
paths := map[string]string{}
|
||||
for _, name := range []string{"lark-shared", "lark-suite"} {
|
||||
paths[name] = filepath.Join(dir, name)
|
||||
writeTestSkill(t, paths[name], name)
|
||||
}
|
||||
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONFromPaths(paths),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err != nil {
|
||||
t.Fatalf("SyncSkills() err = %v, want nil", result.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(paths["lark-shared"], "SKILL.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("lark-shared should not stay top-level when flat set is empty; err: %v", err)
|
||||
}
|
||||
if result.Flat == nil || len(result.Flat) != 0 {
|
||||
t.Fatalf("result.Flat = %#v, want empty slice", result.Flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_HybridRejectsSharedFlatSkill(t *testing.T) {
|
||||
runner := &fakeSkillsRunner{
|
||||
officialIndexOut: officialSkillsIndexOutput("lark-calendar", "lark-shared"),
|
||||
globalJSONOut: globalSkillsJSONOutput("lark-calendar", "lark-shared"),
|
||||
}
|
||||
|
||||
result := SyncSkills(SyncOptions{
|
||||
Version: "1.0.33",
|
||||
Layout: LayoutHybrid,
|
||||
FlatSkills: []string{"lark-shared"},
|
||||
Runner: runner,
|
||||
Now: time.Now,
|
||||
})
|
||||
|
||||
if result.Err == nil || !strings.Contains(result.Err.Error(), "lark-shared") {
|
||||
t.Fatalf("SyncSkills() err = %v, want lark-shared validation error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncSkills_ParseEmptyWithNonEmptyStdoutFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
@@ -853,43 +733,6 @@ func TestSyncSkills_ParseEmptyWithNonEmptyStdoutAndFullInstallFails(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSuiteTemplateTextRewritesLegacyFlatSkillWording(t *testing.T) {
|
||||
input := "`lark-shared` 是共享基础能力,不作为 `--collected-skills` 的可选项。为了保证 suite 内子能力可用,hybrid 布局会同时保留顶层 `lark-shared`,并在 `lark-suite/references/subskills/lark-shared/SKILL.md` 中维护一份副本。"
|
||||
got := normalizeSuiteTemplateText(input)
|
||||
if strings.Contains(got, "--collected-skills") {
|
||||
t.Fatalf("normalized text still contains legacy flag: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--flat-skills") {
|
||||
t.Fatalf("normalized text missing --flat-skills: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份") {
|
||||
t.Fatalf("normalized text missing current lark-shared rule: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillDescriptionSupportsFoldedYAMLScalar(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "SKILL.md")
|
||||
content := `---
|
||||
name: lark-whiteboard
|
||||
description: >
|
||||
飞书画板:查询和编辑飞书云文档中的画板。
|
||||
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
---
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := skillDescription(path)
|
||||
want := "飞书画板:查询和编辑飞书云文档中的画板。 当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。"
|
||||
if got != want {
|
||||
t.Fatalf("skillDescription() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStrings(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
@@ -897,38 +740,6 @@ func assertStrings(t *testing.T, got, want []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestSkill(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := fmt.Sprintf("---\nname: %s\ndescription: %s description\n---\n", name, name)
|
||||
if name == suiteSkillName {
|
||||
content = "---\nname: lark-suite\ndescription: Lark suite\n---\n<!-- LARK_SUITE_ROUTES -->\n"
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func globalSkillsJSONFromPaths(paths map[string]string) string {
|
||||
names := make([]string, 0, len(paths))
|
||||
for name := range paths {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"name":%q,"path":%q,"scope":"global","agents":["Codex"]}`, name, paths[name])
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestSyncSkills_FallbackWithUnknownOfficialWritesMinimalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir)
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
name: lark-suite
|
||||
version: 0.1.0
|
||||
description: 飞书/Lark 聚合能力入口:当用户需求涉及本文件列出的任一飞书能力时使用;仅负责选择并加载已安装到本 suite 的 lark-* 子能力,不替代具体子能力的操作细节。
|
||||
metadata:
|
||||
requires:
|
||||
bins:
|
||||
- lark-cli
|
||||
---
|
||||
|
||||
# Lark Suite
|
||||
|
||||
你是飞书/Lark 能力的聚合路由层。你的职责是先判断用户要使用哪个 `lark-*` 子能力,再读取并遵循对应子能力的说明。
|
||||
|
||||
`lark-suite` 不直接承载具体 API 操作步骤。除非对应子能力已被读取,否则不要仅根据本文件拼命令、猜参数或执行复杂操作。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 根据用户意图从下方路由表选择一个或多个子能力;即使用户尚未提供链接、ID 或具体工作表,也先选择能力,再由子能力询问缺失信息。
|
||||
2. 读取对应子能力说明,优先使用 `references/subskills/<skill-name>/SKILL.md`。
|
||||
3. 如果目标能力没有出现在本文件中,不代表当前环境不可用;检查是否存在相关的独立 `lark-*` skill。
|
||||
4. 如果已选中的子能力说明列出必读、前置或继续阅读的文件,只读取该子能力当前任务所需的前置文件;不要读取无关子能力。
|
||||
5. 按目标子能力的说明执行;认证、租户、身份、权限和通用排障优先遵循 `lark-shared`。
|
||||
|
||||
`lark-shared` 是共享基础能力,不作为 `--flat-skills` 的可选项。为了保证 suite 内子能力可用,它始终会进入 `lark-suite/references/subskills/lark-shared/SKILL.md`;只有 hybrid 布局存在平铺 skill 时,顶层才会额外保留一份 `lark-shared`。
|
||||
|
||||
多步任务可以组合多个子能力,但每一步都应由具体子能力驱动。例如“查联系人并发消息”先用 `lark-contact` 解析身份,再用 `lark-im` 发消息。
|
||||
|
||||
## 能力路由
|
||||
|
||||
根据用户意图从以下条目选择对应子能力;如果一个任务涉及多个能力,按实际操作顺序逐步读取并使用对应子能力。
|
||||
|
||||
<!-- LARK_SUITE_ROUTES -->
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type docsHistoryListSpec struct {
|
||||
Doc documentRef
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type docsHistoryRevertSpec struct {
|
||||
Doc documentRef
|
||||
HistoryVersionID string
|
||||
WaitTimeoutMs int
|
||||
}
|
||||
|
||||
type docsHistoryRevertStatusSpec struct {
|
||||
Doc documentRef
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
|
||||
ref, err := parseDocumentRef(raw)
|
||||
if err != nil {
|
||||
return documentRef{}, err
|
||||
}
|
||||
if ref.Kind == "doc" {
|
||||
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
|
||||
if timeoutMs < 0 || timeoutMs > 30000 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
"wait_timeout_ms": spec.WaitTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func docsHistoryAPIPath(docToken, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
|
||||
}
|
||||
|
||||
var DocsHistoryList = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-list",
|
||||
Description: "List Lark document history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-list"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: list document history versions").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryListParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
|
||||
spec := docsHistoryListSpec{
|
||||
Doc: ref,
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "histories"),
|
||||
docsHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevert = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Lark document to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
|
||||
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: revert document history").
|
||||
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Body(docsHistoryRevertBody(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
|
||||
spec := docsHistoryRevertSpec{
|
||||
Doc: ref,
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
|
||||
nil,
|
||||
docsHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var DocsHistoryRevertStatus = common.Shortcut{
|
||||
Service: "docs",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Lark document history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docx:document:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
PostMount: installDocsShortcutHelp("+history-revert-status"),
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL or token", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("OpenAPI: get document history revert status").
|
||||
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
|
||||
Set("document_id", spec.Doc.Token).
|
||||
Params(docsHistoryStatusParams(spec))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
|
||||
spec := docsHistoryRevertStatusSpec{
|
||||
Doc: ref,
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
|
||||
docsHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package doc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestDocsHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
category errs.Category
|
||||
subtype errs.Subtype
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects legacy doc URL",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||
param: "--doc",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: DocsHistoryList,
|
||||
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "revert rejects invalid wait timeout",
|
||||
shortcut: DocsHistoryRevert,
|
||||
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
|
||||
param: "--wait-timeout-ms",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: DocsHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
category: errs.CategoryValidation,
|
||||
subtype: errs.SubtypeInvalidArgument,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
|
||||
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != tt.category {
|
||||
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||
}
|
||||
if problem.Subtype != tt.subtype {
|
||||
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
"wait-timeout-ms": "30000",
|
||||
})
|
||||
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
|
||||
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
|
||||
}
|
||||
|
||||
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
|
||||
"doc": "doxcnHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryList, []string{
|
||||
"+history-list",
|
||||
"--doc", "doxcnHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--doc", "doxcnHistory",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
|
||||
t.Fatalf("wait_timeout_ms = %d, want 0", got)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": []interface{}{"blk_1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--doc", "doxcnHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "partial_failed" {
|
||||
t.Fatalf("status = %#v, want partial_failed", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
failed, _ := data["failed_block_tokens"].([]interface{})
|
||||
if len(failed) != 1 || failed[0] != "blk_1" {
|
||||
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
|
||||
}
|
||||
}
|
||||
|
||||
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestDocsHistoryURLValidationMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports docx documents") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,6 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||
case "update":
|
||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||
case "history-list", "history-revert", "history-revert-status":
|
||||
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||
default:
|
||||
return docsSkillReadCommand
|
||||
}
|
||||
@@ -46,12 +44,6 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
||||
return "lark-cli docs +fetch --help"
|
||||
case "update":
|
||||
return "lark-cli docs +update --help"
|
||||
case "history-list":
|
||||
return "lark-cli docs +history-list --help"
|
||||
case "history-revert":
|
||||
return "lark-cli docs +history-revert --help"
|
||||
case "history-revert-status":
|
||||
return "lark-cli docs +history-revert-status --help"
|
||||
default:
|
||||
return "lark-cli docs --help"
|
||||
}
|
||||
@@ -64,9 +56,6 @@ func Shortcuts() []common.Shortcut {
|
||||
DocsCreate,
|
||||
DocsFetch,
|
||||
DocsUpdate,
|
||||
DocsHistoryList,
|
||||
DocsHistoryRevert,
|
||||
DocsHistoryRevertStatus,
|
||||
DocMediaInsert,
|
||||
DocMediaUpload,
|
||||
DocMediaPreview,
|
||||
|
||||
@@ -170,27 +170,6 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
|
||||
cmd, _, err := program.Find([]string{"docs", name})
|
||||
if err != nil {
|
||||
t.Fatalf("find docs %s shortcut: %v", name, err)
|
||||
}
|
||||
if cmd == nil || cmd.Name() != name {
|
||||
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
|
||||
}
|
||||
if cmd.Flags().Lookup("api-version") != nil {
|
||||
t.Fatalf("docs %s should not expose --api-version", name)
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
|
||||
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||
program := &cobra.Command{Use: "root"}
|
||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
|
||||
cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
|
||||
---
|
||||
|
||||
# docs
|
||||
@@ -21,28 +21,41 @@ 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. **创建、重写、润色、排版或生成较多正文** → 必读:
|
||||
- [`lark-doc-design-philosophy.md`](references/lark-doc-design-philosophy.md) — 文档设计判断:读者任务、结构取舍、组件选择、体裁路由和反模板化。
|
||||
- [`lark-doc-xml.md`](references/lark-doc-xml.md) — XML 语法规则;仅当用户明确要求 Markdown、提供 `.md` 文件,或明确要求导入 Markdown 时,改读 [`lark-doc-md.md`](references/lark-doc-md.md)。
|
||||
|
||||
进一步按场景加读:
|
||||
- 如果 [`lark-doc-design-philosophy.md`](references/lark-doc-design-philosophy.md) 的体裁路由命中某个 `references/genres/*.md` 文件,只读取该体裁文件;不要读取整个 `genres/` 目录。
|
||||
- 从零创建文档时,加读 [`lark-doc-create.md`](references/lark-doc-create.md)。
|
||||
- 编辑已有文档时,加读 [`lark-doc-update.md`](references/lark-doc-update.md)。
|
||||
|
||||
4. **仅做微编辑**,例如明确旧文本 → 新文本的简单替换、错别字修正、日期 / 人名 / 数字替换,不需要读取设计哲学和体裁文件;按 [`lark-doc-update.md`](references/lark-doc-update.md) 执行,并在写后 fetch 验证。
|
||||
|
||||
**Philosophy 负责判断;Genre 负责偏好;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),按其中流程操作
|
||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||
@@ -70,7 +83,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||
|
||||
38
skills/lark-doc/references/genres/formal-doc.md
Normal file
38
skills/lark-doc/references/genres/formal-doc.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Genre: Formal Document
|
||||
|
||||
适用:通知、制度、公告、汇报、立项、正式方案、对外说明、需要归档或跨团队协作的文档。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构,不要机械套用:
|
||||
|
||||
- **事项通知**:事项 → 适用范围 → 具体安排 → 时间节点 → 责任人 / 联系方式。
|
||||
- **决策汇报**:结论 / 建议 → 背景 → 方案对比 → 风险影响 → 待决策问题。
|
||||
- **制度规范**:目的 → 适用范围 → 规则正文 → 例外情况 → 执行与解释。
|
||||
- **正式方案**:问题定义 → 目标与范围 → 方案 → 资源 / 排期 → 风险 → 下一步。
|
||||
|
||||
## Style
|
||||
|
||||
准确、克制、明确责任边界。优先写清对象、动作、时间、范围和责任人。少用夸张修饰,不使用网感标题。避免用“赋能、闭环、抓手、沉淀、拉通、对齐”等空泛词替代具体动作。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 时间、责任、范围、事项清单:优先 table。
|
||||
- 风险、待确认、强约束:可用 callout。
|
||||
- 方案对比:table / grid / 决策矩阵。
|
||||
- 流程、组织关系、里程碑:必要时用 whiteboard。
|
||||
- 长期归档、对外说明:少用颜色和 emoji,保持朴素。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 开头铺太长背景,迟迟不给结论。
|
||||
- 缺少责任人、时间、范围、生效条件。
|
||||
- 为了正式感堆砌抽象词。
|
||||
- 为了美观强行加入 callout、grid 或画板。
|
||||
|
||||
## Final check
|
||||
|
||||
- 事项、范围、责任、时间是否明确?
|
||||
- 结论和待决策问题是否前置?
|
||||
- 语气是否正式、克制、无夸张?
|
||||
- 是否适合归档和后续追责?
|
||||
39
skills/lark-doc/references/genres/prd.md
Normal file
39
skills/lark-doc/references/genres/prd.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Genre: PRD / Product Requirement
|
||||
|
||||
适用:PRD、需求说明、用户故事、产品方案、功能设计、版本范围说明。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构:
|
||||
|
||||
- **标准 PRD**:背景与问题 → 用户与场景 → 目标与非目标 → 方案概述 → 功能范围 → 交互 / 流程 → 数据与埋点 → 边界异常 → 验收标准 → 风险依赖。
|
||||
- **轻量需求**:问题 → 目标 → 用户故事 → 规则 → 验收标准 → 待确认。
|
||||
- **产品方案**:现状 → 机会 / 问题 → 方案 → 取舍 → 里程碑 → 风险。
|
||||
- **功能评审**:结论 → 范围变化 → 核心流程 → 争议点 → 待决策问题。
|
||||
|
||||
## Style
|
||||
|
||||
具体、可评审、可验收。多写用户行为、系统响应、状态变化和规则,少写抽象愿景。每个需求尽量落到状态、规则、字段、交互或验收条件。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 用户故事、验收标准:列表 / checkbox。
|
||||
- 字段、规则、状态、埋点:table。
|
||||
- 用户流程、系统流程、状态机:whiteboard。
|
||||
- 风险、依赖、待确认:callout。
|
||||
- 版本范围、需求优先级:table / 小标题。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只有需求描述,没有验收标准。
|
||||
- 混淆目标、方案、交互和实现细节。
|
||||
- 没有非目标,导致范围无限扩大。
|
||||
- 缺少异常状态、边界条件和数据规则。
|
||||
- 用“大幅提升体验”替代具体用户收益。
|
||||
|
||||
## Final check
|
||||
|
||||
- 目标、非目标和范围是否清楚?
|
||||
- 用户、场景、规则和状态是否可评审?
|
||||
- 验收标准是否可验证?
|
||||
- 风险、依赖和待确认项是否明确?
|
||||
39
skills/lark-doc/references/genres/retrospective.md
Normal file
39
skills/lark-doc/references/genres/retrospective.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Genre: Retrospective / Summary
|
||||
|
||||
适用:项目复盘、事故复盘、阶段总结、周报、月报、季度总结、问题回顾。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构:
|
||||
|
||||
- **事故复盘**:结论 → 影响范围 → 时间线 → 根因 → 处置过程 → 暴露问题 → 改进动作 → 负责人 / 截止时间。
|
||||
- **项目复盘**:目标 → 结果 → 关键事实 → 做得好的地方 → 问题与原因 → 经验沉淀 → 后续动作。
|
||||
- **周报 / 月报**:核心进展 → 关键结果 → 风险阻塞 → 下阶段计划 → 需要支持。
|
||||
- **阶段总结**:目标回顾 → 数据 / 事实 → 判断 → 问题 → 后续重点。
|
||||
|
||||
## Style
|
||||
|
||||
事实和判断分开。避免甩锅,也避免空泛反思。后续动作必须具体到负责人、时间和验收方式。不要用“加强对齐、持续优化、提升意识”替代真实动作。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 时间线:table / whiteboard。
|
||||
- 根因和影响链路:列表 / 鱼骨图 / 因果图。
|
||||
- 行动项:checkbox / table。
|
||||
- 风险和阻塞:callout。
|
||||
- 数据对比:table / whiteboard 图表。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只有情绪评价,没有事实。
|
||||
- 只有“后续加强”,没有负责人和时间。
|
||||
- 时间线缺失,无法复盘过程。
|
||||
- 结论与证据不匹配。
|
||||
- 把复盘写成汇功或甩锅材料。
|
||||
|
||||
## Final check
|
||||
|
||||
- 事实、判断、动作是否分开?
|
||||
- 时间线和影响范围是否清楚?
|
||||
- 根因是否有证据支持?
|
||||
- 后续动作是否有负责人、截止时间和验收标准?
|
||||
41
skills/lark-doc/references/genres/social-xhs.md
Normal file
41
skills/lark-doc/references/genres/social-xhs.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Genre: Xiaohongshu / Social Content
|
||||
|
||||
适用:小红书笔记、爆文、种草、攻略、经验分享、标题优化、社媒传播型内容。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构:
|
||||
|
||||
- **经验型**:强钩子 → 痛点场景 → 个人经验 / 观察 → 方法清单 → 适用人群 → 互动结尾。
|
||||
- **攻略型**:结果承诺 → 准备条件 → 步骤 → 避坑点 → 清单总结。
|
||||
- **种草型**:使用场景 → 核心卖点 → 真实体验 → 对比 / 注意事项 → 行动建议。
|
||||
- **观点型**:反常识判断 → 论据 → 例子 → 常见误区 → 行动建议。
|
||||
- **清单型**:适用人群 → 清单分组 → 每项解释 → 收藏提醒 / 使用方式。
|
||||
|
||||
## Style
|
||||
|
||||
口语化、具体、有节奏。短段落优先,多用场景、例子和清单。可以有情绪,但不能编造经历、数据或效果。标题可以吸引人,但正文必须兑现标题承诺。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 开头钩子:短段落 / callout。
|
||||
- 方法步骤:列表 / checkbox。
|
||||
- 避坑、对比、清单:grid / table。
|
||||
- 可收藏内容:checkbox / table。
|
||||
- 复杂流程才用 whiteboard;轻内容不要图表化。
|
||||
- emoji 可用,但要克制并保持语义一致。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 标题党但正文兑现不了。
|
||||
- 过度使用“绝了、封神、天花板、救命、狠狠爱住”等空泛网感词。
|
||||
- 编造个人经历、效果数据、医学 / 金融 / 法律结论。
|
||||
- 信息太散,没有可保存的方法、清单或判断。
|
||||
- 为了网感牺牲事实准确性。
|
||||
|
||||
## Final check
|
||||
|
||||
- 标题是否有吸引力且不虚假?
|
||||
- 开头 3 秒内是否说明痛点或利益?
|
||||
- 正文是否有具体方法、例子或清单?
|
||||
- 是否适合收藏、转发或执行?
|
||||
40
skills/lark-doc/references/genres/sop-tutorial.md
Normal file
40
skills/lark-doc/references/genres/sop-tutorial.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Genre: SOP / Tutorial
|
||||
|
||||
适用:SOP、操作手册、培训材料、入门指南、使用说明、FAQ、流程规范。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构:
|
||||
|
||||
- **SOP**:适用范围 → 前置条件 → 操作步骤 → 检查标准 → 异常处理 → 责任边界。
|
||||
- **入门教程**:适合谁 → 准备什么 → 快速开始 → 分步说明 → 常见问题 → 下一步。
|
||||
- **操作手册**:任务列表 → 每个任务的步骤 → 注意事项 → 错误处理 → 参考链接。
|
||||
- **FAQ**:问题分类 → 问题 → 简短答案 → 详细说明 / 操作入口。
|
||||
|
||||
## Style
|
||||
|
||||
直接、清楚、面向操作者。一句话只承担一个任务。按用户看到的界面命名,不按系统内部实现命名。优先使用动词开头的步骤,例如“打开”“选择”“填写”“确认”。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 步骤:有序列表。
|
||||
- 检查项:checkbox。
|
||||
- 命令、配置、日志:pre / code。
|
||||
- 错误处理、条件分支:table / callout。
|
||||
- 流程复杂时:whiteboard。
|
||||
- 入口链接:button / link / url-preview。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 缺少前置条件。
|
||||
- 步骤跳跃,默认读者知道上下文。
|
||||
- 用内部术语命名用户界面。
|
||||
- 没有失败处理或校验方式。
|
||||
- 一个步骤里塞多个动作。
|
||||
|
||||
## Final check
|
||||
|
||||
- 新手能否按步骤完成任务?
|
||||
- 前置条件、输入、输出和校验是否清楚?
|
||||
- 异常情况是否有处理方式?
|
||||
- 每一步是否只做一件事?
|
||||
40
skills/lark-doc/references/genres/technical-doc.md
Normal file
40
skills/lark-doc/references/genres/technical-doc.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Genre: Technical Document
|
||||
|
||||
适用:技术方案、架构设计、接口说明、API 文档、部署手册、迁移方案、排障指南、研发交接。
|
||||
|
||||
## Structure patterns
|
||||
|
||||
按任务选择结构:
|
||||
|
||||
- **技术方案**:问题 / 背景 → 目标与非目标 → 约束 → 总体设计 → 模块设计 → 数据 / 接口 → 风险 → 验证 → 灰度 / 回滚。
|
||||
- **接口文档**:适用范围 → 鉴权 → 请求参数 → 响应结构 → 错误码 → 示例 → 兼容性。
|
||||
- **排障指南**:现象 → 影响范围 → 快速判断 → 排查步骤 → 常见原因 → 修复方式 → 升级路径。
|
||||
- **迁移方案**:范围 → 前置条件 → 步骤 → 校验 → 回滚 → 风险与窗口期。
|
||||
- **架构文档**:目标 → 上下文 → 架构图 → 核心链路 → 模块职责 → 数据流 → 边界限制。
|
||||
|
||||
## Style
|
||||
|
||||
精确、朴素、可实现、可验证。术语保持一致,不为了通俗牺牲准确性。不确定处标注“假设”或“待确认”。不要把技术取舍写成宣传话术。
|
||||
|
||||
## Component bias
|
||||
|
||||
- 命令、代码、配置、日志:pre / code。
|
||||
- 参数、字段、错误码、兼容性:table。
|
||||
- 架构、调用链、状态流转、部署拓扑:whiteboard / Mermaid。
|
||||
- 操作步骤、验收项:列表 / checkbox。
|
||||
- 风险、限制、回滚条件:callout。
|
||||
|
||||
## Avoid
|
||||
|
||||
- 只有概念,没有接口、数据、边界或验证。
|
||||
- 忽略失败场景、兼容性、监控、回滚。
|
||||
- 图很多,但没有说明依赖、调用关系或数据流。
|
||||
- 使用营销腔描述技术收益。
|
||||
- 改写时破坏代码、图片、引用、资源块或 token 化内容。
|
||||
|
||||
## Final check
|
||||
|
||||
- 读者能否据此实现、集成、排查或评审?
|
||||
- 目标、非目标、边界和失败场景是否清楚?
|
||||
- 参数、命令、接口和示例是否准确?
|
||||
- 是否有验证、灰度、回滚或排障路径?
|
||||
@@ -1,15 +1,30 @@
|
||||
# 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 串行撰写)
|
||||
> 1. [`lark-doc-design-philosophy.md`](lark-doc-design-philosophy.md) — 文档设计判断(读者任务、结构取舍、组件选择、反模板化)
|
||||
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
> **未读完以上文件就生成内容会导致结构失焦或格式错误。**
|
||||
|
||||
从 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 +80,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) — 更新文档
|
||||
|
||||
98
skills/lark-doc/references/lark-doc-design-philosophy.md
Normal file
98
skills/lark-doc/references/lark-doc-design-philosophy.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Lark Doc Design Philosophy
|
||||
|
||||
- 把飞书云文档当作帮助读者完成理解、判断、决策或行动的界面,而不是内容容器。你的任务不是把信息铺满页面,而是为具体读者设计一条清晰、可信、可执行的阅读路径。
|
||||
- 本文件负责文档设计判断:读者任务、结构取舍、表达组件、反模板化和质量自检。具体执行流程见 `lark-doc-create.md` 和 `lark-doc-update.md`;XML / Markdown 语法见 `lark-doc-xml.md` / `lark-doc-md.md`;体裁结构和语气差异按下方路由读取 `genres/*.md`。
|
||||
|
||||
## 0. Priority order
|
||||
|
||||
当目标冲突时,按此顺序取舍:**事实准确性 > 信息层级 > 可扫描性 > 协作可执行性 > 视觉整洁 > 个性化表达**。事实不确定就标注假设 / 待确认;结构混乱先调信息层级;视觉和个性只能增强理解,不能牺牲事实、结构和执行。
|
||||
|
||||
## 1. Ground it in the document job
|
||||
|
||||
写作或改写前,先判断这篇文档的工作:
|
||||
|
||||
- 读者是谁:决策者、执行者、评审者、客户、开发者、运营者,还是未来维护者?
|
||||
- 读者要完成什么:理解背景、做出决策、执行任务、评审方案、排查问题、学习方法、传播内容,还是留档备查?
|
||||
- 读完后读者应该能做什么:拍板、执行、确认风险、复用、转发、评论、排查或完成任务?
|
||||
- 文档寿命是什么:一次性沟通、项目周期内维护、长期知识库、正式归档,还是外部传播?
|
||||
|
||||
如果用户没有明确这些信息,能安全假设时说明假设并继续;事实来源、产品口径、结论归属不确定时,先询问或标注待确认,不要编造确定性。
|
||||
|
||||
## 2. Route by genre
|
||||
|
||||
体裁决定结构、语气、证据密度和表达方式。不要用同一套“背景 / 目标 / 方案 / 风险 / 下一步”应对所有任务。
|
||||
|
||||
当用户明确或暗示体裁时,只读取一个最匹配的体裁文件;不要读取整个 `genres/` 目录。简单编辑已有文档时,优先沿用原文体裁和风格,不额外读取体裁文件。
|
||||
|
||||
| 用户意图 / 信号词 | 读取文件 |
|
||||
|---|---|
|
||||
| 通知、制度、公告、汇报、立项、正式方案、对外说明 | `genres/formal-doc.md` |
|
||||
| 架构、接口、API、部署、迁移、排障、研发方案、技术设计 | `genres/technical-doc.md` |
|
||||
| 小红书、爆文、种草、笔记、攻略、社媒传播、标题优化 | `genres/social-xhs.md` |
|
||||
| PRD、需求说明、用户故事、产品方案、功能设计 | `genres/prd.md` |
|
||||
| 复盘、事故总结、项目总结、周报、月报、阶段总结 | `genres/retrospective.md` |
|
||||
| SOP、操作手册、培训材料、入门指南、使用说明、FAQ | `genres/sop-tutorial.md` |
|
||||
|
||||
如果任务混合多个体裁,优先选择决定**内容结构**的主文件;语气可以参考用户要求调整,不要为轻微语气差异读取第二个体裁文件。例如“写给领导看的技术方案”优先读 `technical-doc.md`,并采用更正式克制的语气。
|
||||
|
||||
## 3. Structure is information
|
||||
|
||||
- 结构不是装饰。标题、顺序、列表、表格、callout、grid、画板和引用块,都应该表达真实的信息关系。
|
||||
- 选择结构前先判断:这是顺序、并列、对比、因果、层级、状态、证据,还是行动关系?读者需要先知道什么,才能理解后面的内容?哪些信息应该前置为结论,哪些适合压缩或放到附录?
|
||||
- 文档开头不是固定的“背景”。决策文档先给结论和待拍板问题;执行文档先给目标、范围和时间线;复盘文档先给事实结论、影响和后续动作;技术文档先给适用范围、前置条件和快速路径;传播文档先给痛点、利益点或钩子。
|
||||
- 较长、复杂或面向传播 / 汇报 / 决策的文档,最好有一个主组织锚点,如一句话结论、决策矩阵、风险雷达、路线图、架构图、时间线、对比表或 checklist。只保留一个,不要到处制造亮点;短文档、微编辑、正式归档或简单 SOP 不必强行设计。锚点必须服务事实准确、信息层级和协作执行,不能为了个性化表达增加理解成本。
|
||||
|
||||
## 4. 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`。
|
||||
- 颜色只在帮助识别语义时使用;正式文档、技术文档、长期知识库和归档材料优先保持朴素。
|
||||
|
||||
## 5. Writing style
|
||||
|
||||
- 从读者一侧写,而不是从系统实现一侧写。用读者能识别和控制的事物命名,默认使用具体、主动、可执行的表达。
|
||||
- 具体比聪明更好。一致比花哨更重要。文档词汇是读者导航的路标,同一动作、状态、对象在全文中保持同名。
|
||||
|
||||
## 6. Calibrate against AI-doc defaults
|
||||
|
||||
写作或改写前,主动避开这些 AI 文档默认值:
|
||||
|
||||
1. 不管任务是什么都套“背景 / 目标 / 方案 / 风险 / 下一步”。
|
||||
2. 用“赋能、闭环、抓手、沉淀、拉通、对齐”等空泛词替代具体动作。
|
||||
3. 开头写摘要,但没有结论、取舍、风险或行动。
|
||||
4. 为了“丰富”强行加入 callout、table、grid 或 whiteboard。
|
||||
5. 把所有重要信息都画板化。
|
||||
6. 标题整齐,但章节之间没有真实推进关系。
|
||||
7. 章节之间像多个 Agent 拼接,术语、语气、事实口径不一致。
|
||||
8. 改写已有文档时全文覆盖,破坏评论、引用、图片、画板或资源块。
|
||||
|
||||
如果你的计划看起来像面对任何类似请求都会写出的文档,就修改它,使结构、语气和组件选择更贴合当前读者、任务和体裁。
|
||||
|
||||
## 7. Final self-check
|
||||
|
||||
交付前检查:
|
||||
|
||||
| 指标 | 自检问题 |
|
||||
|---|---|
|
||||
| 读者任务 | 这篇文档是否帮助目标读者完成理解、判断、决策或行动? |
|
||||
| 体裁匹配 | 结构、语气和证据密度是否符合当前体裁? |
|
||||
| 信息结构 | 标题、顺序、列表、表格和组件是否表达真实关系? |
|
||||
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多? |
|
||||
| 组件必要性 | callout、grid、table、whiteboard 是否真的降低理解成本? |
|
||||
| 风格一致 | 是否延续用户给定样例或已有文档风格? |
|
||||
| 事实保真 | 改写时是否保留事实、引用、图片、附件、资源块和关键措辞? |
|
||||
| 行动清晰 | 读者下一步应该做什么是否明确? |
|
||||
@@ -1,107 +0,0 @@
|
||||
# docs history(历史版本与回滚)
|
||||
|
||||
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||
|
||||
## 安全流程
|
||||
|
||||
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
|
||||
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
|
||||
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
|
||||
|
||||
## 按 revision_id 或时间点回滚
|
||||
|
||||
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||
|
||||
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`。
|
||||
|
||||
候选确认时使用类似格式:
|
||||
|
||||
```text
|
||||
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
|
||||
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
|
||||
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 列出历史版本
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
|
||||
|
||||
# 翻页
|
||||
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||
|
||||
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
|
||||
|
||||
# 只发起任务,不等待
|
||||
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||
|
||||
# 查询回滚任务状态
|
||||
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 命令 | 参数 | 必填 | 说明 |
|
||||
|-|-|-|-|
|
||||
| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||
| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
|
||||
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||
|
||||
## 返回值要点
|
||||
|
||||
`+history-list` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"revision_id": 42,
|
||||
"history_version_id": "11",
|
||||
"edit_time": "1780000000",
|
||||
"type": 1,
|
||||
"name": "版本名",
|
||||
"description": "版本说明",
|
||||
"editor_ids": ["ou_xxx"]
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"page_token": "page_token"
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task_xxx",
|
||||
"status": "running",
|
||||
"history_version_id": "11",
|
||||
"poll_after_ms": 10000
|
||||
}
|
||||
```
|
||||
|
||||
`+history-revert-status` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "partial_failed",
|
||||
"history_version_id": "11",
|
||||
"failed_block_tokens": ["blk_xxx"]
|
||||
}
|
||||
```
|
||||
|
||||
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||
@@ -1,12 +1,11 @@
|
||||
|
||||
# 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 串行改写)
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
> **前置条件(MUST READ):**
|
||||
> - 仅做明确旧文本 → 新文本的简单替换、错别字修正、日期 / 人名 / 数字替换时,读取本文件即可;写后必须 fetch 验证。
|
||||
> - 涉及新增正文、重写段落、调整结构、润色语气、美化排版、补充图表或保真改写时,必须读取:
|
||||
> 1. [`lark-doc-design-philosophy.md`](lark-doc-design-philosophy.md) — 文档设计判断(读者任务、结构取舍、组件选择、反模板化)
|
||||
> 2. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
|
||||
通过八种指令精确更新飞书云文档。支持字符串级别和 block 级别的操作。
|
||||
|
||||
@@ -16,6 +15,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 +64,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 +216,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)
|
||||
- 图表目标、受众、源段落或数据
|
||||
|
||||
@@ -13,13 +13,6 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||
|
||||
## 统计范围
|
||||
|
||||
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||
|
||||
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||
|
||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||
|
||||
```bash
|
||||
@@ -43,15 +36,6 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
||||
|
||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||
|
||||
## 字数遵循校验
|
||||
|
||||
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||
|
||||
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||
|
||||
## 输出示例
|
||||
|
||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||
|
||||
@@ -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,46 +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` 创建并撰写:
|
||||
- **短文档**:一次写入完整内容
|
||||
- **长文档**:先建骨架(标题 + 各级标题),再由主 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. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||
@@ -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` 精确区间,只拉当前章节,不要重复拉全文。
|
||||
@@ -1,9 +1,9 @@
|
||||
# Docs CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 11 leaf commands
|
||||
- Covered: 6
|
||||
- Coverage: 54.5%
|
||||
- Denominator: 8 leaf commands
|
||||
- Covered: 3
|
||||
- Coverage: 37.5%
|
||||
|
||||
## Summary
|
||||
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
|
||||
@@ -11,8 +11,6 @@
|
||||
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
|
||||
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
||||
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
|
||||
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
|
||||
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
||||
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
||||
|
||||
@@ -22,9 +20,6 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
||||
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
|
||||
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
|
||||
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
||||
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDocs_HistoryWorkflow(t *testing.T) {
|
||||
if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" {
|
||||
t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow")
|
||||
}
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-docs-history-folder-" + suffix
|
||||
docTitle := "lark-cli-e2e-docs-history-" + suffix
|
||||
originalMarker := "original history marker " + suffix
|
||||
updatedMarker := "updated history marker " + suffix
|
||||
const defaultAs = "user"
|
||||
|
||||
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
|
||||
docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs)
|
||||
|
||||
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", docToken,
|
||||
"--command", "overwrite",
|
||||
"--doc-format", "markdown",
|
||||
"--content", "# " + docTitle + "\n\n" + updatedMarker,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
updateResult.AssertExitCode(t, 0)
|
||||
updateResult.AssertStdoutStatus(t, true)
|
||||
|
||||
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchUpdated.AssertExitCode(t, 0)
|
||||
fetchUpdated.AssertStdoutStatus(t, true)
|
||||
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String()
|
||||
assert.Contains(t, updatedContent, updatedMarker)
|
||||
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int()
|
||||
require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout)
|
||||
|
||||
var revertHistoryVersionID string
|
||||
require.Eventually(t, func() bool {
|
||||
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+history-list",
|
||||
"--doc", docToken,
|
||||
"--page-size", "20",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, listErr)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
|
||||
revisionID := entry.Get("revision_id").Int()
|
||||
historyVersionID := entry.Get("history_version_id").String()
|
||||
if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" {
|
||||
revertHistoryVersionID = historyVersionID
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 45*time.Second, 3*time.Second, "history list did not expose a prior revision")
|
||||
require.NotEmpty(t, revertHistoryVersionID)
|
||||
|
||||
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+history-revert",
|
||||
"--doc", docToken,
|
||||
"--history-version-id", revertHistoryVersionID,
|
||||
"--wait-timeout-ms", "30000",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
revertResult.AssertExitCode(t, 0)
|
||||
revertResult.AssertStdoutStatus(t, true)
|
||||
|
||||
status := gjson.Get(revertResult.Stdout, "data.status").String()
|
||||
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
|
||||
statusStdout := revertResult.Stdout
|
||||
if status == "running" {
|
||||
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
|
||||
require.Eventually(t, func() bool {
|
||||
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+history-revert-status",
|
||||
"--doc", docToken,
|
||||
"--task-id", taskID,
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, statusErr)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
statusStdout = statusResult.Stdout
|
||||
status = gjson.Get(statusResult.Stdout, "data.status").String()
|
||||
return status != "" && status != "running"
|
||||
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
|
||||
}
|
||||
require.Equal(t, "done", status, "status stdout:\n%s", statusStdout)
|
||||
|
||||
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchReverted.AssertExitCode(t, 0)
|
||||
fetchReverted.AssertStdoutStatus(t, true)
|
||||
revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String()
|
||||
assert.Contains(t, revertedContent, originalMarker)
|
||||
assert.NotContains(t, revertedContent, updatedMarker)
|
||||
}
|
||||
@@ -26,10 +26,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantContains []string
|
||||
wantURL string
|
||||
wantParams map[string]any
|
||||
wantBody map[string]any
|
||||
wantExtraParam string
|
||||
wantRefLabel string
|
||||
}{
|
||||
@@ -40,7 +37,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--content", "<title>Dry Run</title><p>hello</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents",
|
||||
},
|
||||
{
|
||||
name: "create api-version v1 compatibility",
|
||||
@@ -50,7 +47,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--content", "<title>Dry Run</title><p>hello</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents",
|
||||
},
|
||||
{
|
||||
name: "fetch",
|
||||
@@ -59,7 +56,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
|
||||
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
|
||||
},
|
||||
{
|
||||
@@ -71,7 +68,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--content", "<p>hello</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
},
|
||||
{
|
||||
name: "update reference-map",
|
||||
@@ -83,7 +80,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
wantRefLabel: "widget-ref-value",
|
||||
},
|
||||
{
|
||||
@@ -95,50 +92,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"--block-id", "blkA,blkB,blkC",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
},
|
||||
{
|
||||
name: "history list",
|
||||
args: []string{
|
||||
"docs", "+history-list",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories",
|
||||
wantParams: map[string]any{
|
||||
"page_size": 5,
|
||||
"page_token": "page_token_1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "history revert",
|
||||
args: []string{
|
||||
"docs", "+history-revert",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--history-version-id", "42",
|
||||
"--wait-timeout-ms", "0",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert",
|
||||
wantBody: map[string]any{
|
||||
"history_version_id": "42",
|
||||
"wait_timeout_ms": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "history revert status",
|
||||
args: []string{
|
||||
"docs", "+history-revert-status",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--task-id", "task_1",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status",
|
||||
wantParams: map[string]any{
|
||||
"task_id": "task_1",
|
||||
},
|
||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -152,7 +106,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
for _, want := range append(tt.wantContains, "docs_ai/v1") {
|
||||
for _, want := range []string{
|
||||
tt.wantURL,
|
||||
"docs_ai/v1",
|
||||
} {
|
||||
if !strings.Contains(combined, want) {
|
||||
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
|
||||
}
|
||||
@@ -163,15 +120,6 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
if strings.Contains(combined, "--api-version") {
|
||||
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
if tt.wantURL != "" {
|
||||
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
|
||||
}
|
||||
for key, want := range tt.wantParams {
|
||||
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
|
||||
}
|
||||
for key, want := range tt.wantBody {
|
||||
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
|
||||
}
|
||||
if tt.wantExtraParam != "" {
|
||||
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
|
||||
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
|
||||
@@ -184,21 +132,6 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertDryRunField(t *testing.T, stdout, path string, want any) {
|
||||
t.Helper()
|
||||
|
||||
got := gjson.Get(stdout, path)
|
||||
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
|
||||
switch want := want.(type) {
|
||||
case int:
|
||||
require.Equal(t, int64(want), got.Int(), "%s in stdout:\n%s", path, stdout)
|
||||
case string:
|
||||
require.Equal(t, want, got.String(), "%s in stdout:\n%s", path, stdout)
|
||||
default:
|
||||
t.Fatalf("unsupported dry-run assertion type %T for %s", want, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||
// Fake creds are enough — dry-run short-circuits before any real API call.
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
|
||||
Reference in New Issue
Block a user