From 91d94dbb75ac1b7f4c67a494a9c6b3f66cef94b1 Mon Sep 17 00:00:00 2001 From: liujinkun Date: Wed, 8 Apr 2026 16:00:40 +0800 Subject: [PATCH] add wiki node create shortcut Change-Id: I4810fc541c31ae9e3e08539d4b1c91d01f53b7f5 --- shortcuts/register.go | 2 + shortcuts/wiki/shortcuts.go | 13 + shortcuts/wiki/wiki_node_create.go | 464 +++++++++++++++++ shortcuts/wiki/wiki_node_create_test.go | 483 ++++++++++++++++++ skills/lark-wiki/SKILL.md | 8 + .../references/lark-wiki-node-create.md | 101 ++++ 6 files changed, 1071 insertions(+) create mode 100644 shortcuts/wiki/shortcuts.go create mode 100644 shortcuts/wiki/wiki_node_create.go create mode 100644 shortcuts/wiki/wiki_node_create_test.go create mode 100644 skills/lark-wiki/references/lark-wiki-node-create.md diff --git a/shortcuts/register.go b/shortcuts/register.go index 3f8048fb..79b08814 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -22,6 +22,7 @@ import ( "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" "github.com/larksuite/cli/shortcuts/whiteboard" + "github.com/larksuite/cli/shortcuts/wiki" ) // allShortcuts aggregates shortcuts from all domain packages. @@ -41,6 +42,7 @@ func init() { allShortcuts = append(allShortcuts, task.Shortcuts()...) allShortcuts = append(allShortcuts, vc.Shortcuts()...) allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...) + allShortcuts = append(allShortcuts, wiki.Shortcuts()...) } // AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts). diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go new file mode 100644 index 00000000..2d4a5015 --- /dev/null +++ b/shortcuts/wiki/shortcuts.go @@ -0,0 +1,13 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all wiki shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + WikiNodeCreate, + } +} diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go new file mode 100644 index 00000000..5a14bcba --- /dev/null +++ b/shortcuts/wiki/wiki_node_create.go @@ -0,0 +1,464 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + wikiNodeTypeOrigin = "origin" + wikiNodeTypeShortcut = "shortcut" + wikiMyLibrarySpaceID = "my_library" + + wikiResolvedByExplicitSpaceID = "explicit_space_id" + wikiResolvedByParentNode = "parent_node_token" + wikiResolvedByMyLibrary = "my_library" +) + +var wikiObjectTypes = []string{ + "sheet", + "mindnote", + "bitable", + "docx", + "slides", +} + +// WikiNodeCreate wraps wiki node creation with shortcut-specific ergonomics: +// it can infer the target space from the parent node or the caller's personal +// document library instead of forcing users to pass a numeric space ID first. +var WikiNodeCreate = common.Shortcut{ + Service: "wiki", + Command: "+node-create", + Description: "Create a wiki node with automatic space resolution", + Risk: "write", + Scopes: []string{"wiki:node:create", "wiki:node:read", "wiki:space:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "space-id", Desc: "target wiki space ID; use my_library for the personal document library"}, + {Name: "parent-node-token", Desc: "parent wiki node token; if set, the new node is created under that parent"}, + {Name: "title", Desc: "node title"}, + {Name: "node-type", Default: wikiNodeTypeOrigin, Desc: "node type", Enum: []string{wikiNodeTypeOrigin, wikiNodeTypeShortcut}}, + {Name: "obj-type", Default: "docx", Desc: "target object type", Enum: wikiObjectTypes}, + {Name: "origin-node-token", Desc: "source node token when --node-type=shortcut"}, + }, + Tips: []string{ + "If --space-id and --parent-node-token are both omitted, user identity falls back to my_library.", + "Use --node-type shortcut --origin-node-token to create a shortcut node.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As()) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := readWikiNodeCreateSpec(runtime) + + fmt.Fprintf(runtime.IO().ErrOut, "Creating wiki node...\n") + execution, err := runWikiNodeCreate(ctx, wikiNodeCreateAPI{runtime: runtime}, runtime.As(), spec) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy) + runtime.Out(wikiNodeCreateOutput(execution), nil) + return nil + }, +} + +// wikiNodeCreateSpec is the normalized CLI input for the shortcut. +type wikiNodeCreateSpec struct { + SpaceID string + ParentNodeToken string + Title string + NodeType string + ObjType string + OriginNodeToken string +} + +// RequestBody converts the normalized shortcut input into the OpenAPI payload. +func (spec wikiNodeCreateSpec) RequestBody() map[string]interface{} { + body := map[string]interface{}{ + "node_type": spec.NodeType, + "obj_type": spec.ObjType, + } + if spec.Title != "" { + body["title"] = spec.Title + } + if spec.ParentNodeToken != "" { + body["parent_node_token"] = spec.ParentNodeToken + } + if spec.OriginNodeToken != "" { + body["origin_node_token"] = spec.OriginNodeToken + } + return body +} + +// wikiNodeRecord contains the response fields used by the shortcut. +type wikiNodeRecord struct { + SpaceID string + NodeToken string + ObjToken string + ObjType string + ParentNodeToken string + NodeType string + OriginNodeToken string + Title string + HasChild bool +} + +// wikiSpaceRecord contains the response fields used when resolving spaces. +type wikiSpaceRecord struct { + SpaceID string + Name string + SpaceType string + Visibility string + OpenSharing string +} + +// wikiResolvedSpace captures both the final numeric space ID and how it was +// derived. Keeping the provenance separate makes the command output easier to +// understand and keeps the resolution logic testable. +type wikiResolvedSpace struct { + SpaceID string + ResolvedBy string + ParentNode *wikiNodeRecord +} + +type wikiNodeCreateExecution struct { + Node *wikiNodeRecord + ResolvedSpace wikiResolvedSpace +} + +// wikiNodeCreateClient isolates the network operations so the resolution logic +// can be unit-tested without real HTTP calls. +type wikiNodeCreateClient interface { + GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) + GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) + CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) +} + +type wikiNodeCreateAPI struct { + runtime *common.RuntimeContext +} + +func (api wikiNodeCreateAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { + data, err := api.runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": token}, + nil, + ) + if err != nil { + return nil, err + } + return parseWikiNodeRecord(common.GetMap(data, "node")) +} + +func (api wikiNodeCreateAPI) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) { + data, err := api.runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)), + nil, + nil, + ) + if err != nil { + return nil, err + } + return parseWikiSpaceRecord(common.GetMap(data, "space")) +} + +func (api wikiNodeCreateAPI) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) { + data, err := api.runtime.CallAPI( + "POST", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)), + nil, + spec.RequestBody(), + ) + if err != nil { + return nil, err + } + return parseWikiNodeRecord(common.GetMap(data, "node")) +} + +func readWikiNodeCreateSpec(runtime *common.RuntimeContext) wikiNodeCreateSpec { + return wikiNodeCreateSpec{ + SpaceID: strings.TrimSpace(runtime.Str("space-id")), + ParentNodeToken: strings.TrimSpace(runtime.Str("parent-node-token")), + Title: strings.TrimSpace(runtime.Str("title")), + NodeType: strings.ToLower(strings.TrimSpace(runtime.Str("node-type"))), + ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))), + OriginNodeToken: strings.TrimSpace(runtime.Str("origin-node-token")), + } +} + +func validateWikiNodeCreateSpec(spec wikiNodeCreateSpec, identity core.Identity) error { + if err := validateOptionalResourceName(spec.SpaceID, "--space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(spec.ParentNodeToken, "--parent-node-token"); err != nil { + return err + } + if err := validateOptionalResourceName(spec.OriginNodeToken, "--origin-node-token"); err != nil { + return err + } + + if spec.NodeType == wikiNodeTypeShortcut && spec.OriginNodeToken == "" { + return output.ErrValidation("--origin-node-token is required when --node-type=shortcut") + } + if spec.NodeType != wikiNodeTypeShortcut && spec.OriginNodeToken != "" { + return output.ErrValidation("--origin-node-token can only be used when --node-type=shortcut") + } + + // Bot identity has no meaningful "personal document library" target, so + // my_library must be rejected explicitly instead of deferring to API-time + // resolution errors. + if identity.IsBot() && spec.SpaceID == wikiMyLibrarySpaceID { + return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id or --parent-node-token") + } + // Bot identity also cannot fall back implicitly, so it requires an explicit + // target or a parent it can resolve from. + if identity.IsBot() && spec.SpaceID == "" && spec.ParentNodeToken == "" { + return output.ErrValidation("bot identity requires --space-id or --parent-node-token") + } + + return nil +} + +func buildWikiNodeCreateDryRun(spec wikiNodeCreateSpec) *common.DryRunAPI { + dry := common.NewDryRunAPI() + step := 1 + + switch { + case needsMyLibraryLookup(spec) && spec.ParentNodeToken != "": + dry.Desc("3-step orchestration: resolve my_library -> resolve parent node -> create wiki node") + case needsMyLibraryLookup(spec): + dry.Desc("2-step orchestration: resolve my_library -> create wiki node") + case spec.ParentNodeToken != "": + dry.Desc("2-step orchestration: resolve parent node -> create wiki node") + default: + dry.Desc("1-step request: create wiki node") + } + + if needsMyLibraryLookup(spec) { + dry.GET("/open-apis/wiki/v2/spaces/my_library"). + Desc(fmt.Sprintf("[%d] Resolve my_library space ID", step)) + step++ + } + + if spec.ParentNodeToken != "" { + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc(fmt.Sprintf("[%d] Resolve parent node space", step)). + Params(map[string]interface{}{"token": spec.ParentNodeToken}) + step++ + } + + dry.POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", dryRunWikiNodeCreateSpaceID(spec))). + Desc(fmt.Sprintf("[%d] Create wiki node", step)). + Body(spec.RequestBody()) + + return dry +} + +func dryRunWikiNodeCreateSpaceID(spec wikiNodeCreateSpec) string { + if spec.SpaceID != "" && spec.SpaceID != wikiMyLibrarySpaceID { + return spec.SpaceID + } + return "" +} + +func needsMyLibraryLookup(spec wikiNodeCreateSpec) bool { + if spec.ParentNodeToken != "" && spec.SpaceID == "" { + return false + } + return spec.SpaceID == "" || spec.SpaceID == wikiMyLibrarySpaceID +} + +func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (*wikiNodeCreateExecution, error) { + resolvedSpace, err := resolveWikiNodeCreateSpace(ctx, client, identity, spec) + if err != nil { + return nil, err + } + + node, err := client.CreateNode(ctx, resolvedSpace.SpaceID, spec) + if err != nil { + return nil, err + } + + return &wikiNodeCreateExecution{ + Node: node, + ResolvedSpace: resolvedSpace, + }, nil +} + +// resolveWikiNodeCreateSpace applies the shortcut's precedence rules: +// explicit space ID wins, then parent-node inference, then my_library fallback. +func resolveWikiNodeCreateSpace(ctx context.Context, client wikiNodeCreateClient, identity core.Identity, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) { + if spec.SpaceID != "" { + return resolveWikiNodeCreateSpaceFromExplicitSpace(ctx, client, spec) + } + if spec.ParentNodeToken != "" { + return resolveWikiNodeCreateSpaceFromParentNode(ctx, client, spec.ParentNodeToken) + } + if identity.IsBot() { + return wikiResolvedSpace{}, output.ErrValidation("bot identity requires --space-id or --parent-node-token") + } + return resolveWikiNodeCreateSpaceFromMyLibrary(ctx, client) +} + +func resolveWikiNodeCreateSpaceFromExplicitSpace(ctx context.Context, client wikiNodeCreateClient, spec wikiNodeCreateSpec) (wikiResolvedSpace, error) { + resolved := wikiResolvedSpace{ + SpaceID: spec.SpaceID, + ResolvedBy: wikiResolvedByExplicitSpaceID, + } + + if spec.SpaceID == wikiMyLibrarySpaceID { + space, err := client.GetSpace(ctx, wikiMyLibrarySpaceID) + if err != nil { + return wikiResolvedSpace{}, err + } + spaceID, err := requireWikiSpaceID(space) + if err != nil { + return wikiResolvedSpace{}, err + } + resolved.SpaceID = spaceID + resolved.ResolvedBy = wikiResolvedByMyLibrary + } + + if spec.ParentNodeToken == "" { + return resolved, nil + } + + parent, err := client.GetNode(ctx, spec.ParentNodeToken) + if err != nil { + return wikiResolvedSpace{}, err + } + parentSpaceID, err := requireWikiNodeSpaceID(parent) + if err != nil { + return wikiResolvedSpace{}, err + } + if parentSpaceID != resolved.SpaceID { + return wikiResolvedSpace{}, output.ErrValidation( + "--space-id %q does not match parent node space %q (resolved space: %q)", + spec.SpaceID, + parentSpaceID, + resolved.SpaceID, + ) + } + + resolved.ParentNode = parent + return resolved, nil +} + +func resolveWikiNodeCreateSpaceFromParentNode(ctx context.Context, client wikiNodeCreateClient, parentNodeToken string) (wikiResolvedSpace, error) { + parent, err := client.GetNode(ctx, parentNodeToken) + if err != nil { + return wikiResolvedSpace{}, err + } + spaceID, err := requireWikiNodeSpaceID(parent) + if err != nil { + return wikiResolvedSpace{}, err + } + + return wikiResolvedSpace{ + SpaceID: spaceID, + ResolvedBy: wikiResolvedByParentNode, + ParentNode: parent, + }, nil +} + +func resolveWikiNodeCreateSpaceFromMyLibrary(ctx context.Context, client wikiNodeCreateClient) (wikiResolvedSpace, error) { + space, err := client.GetSpace(ctx, wikiMyLibrarySpaceID) + if err != nil { + return wikiResolvedSpace{}, err + } + spaceID, err := requireWikiSpaceID(space) + if err != nil { + return wikiResolvedSpace{}, err + } + + return wikiResolvedSpace{ + SpaceID: spaceID, + ResolvedBy: wikiResolvedByMyLibrary, + }, nil +} + +func requireWikiNodeSpaceID(node *wikiNodeRecord) (string, error) { + if node != nil && node.SpaceID != "" { + return node.SpaceID, nil + } + return "", output.Errorf(output.ExitAPI, "api_error", "wiki node lookup returned no space_id") +} + +func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) { + if space != nil && space.SpaceID != "" { + return space.SpaceID, nil + } + return "", output.ErrValidation("personal document library was not found, please specify --space-id") +} + +func validateOptionalResourceName(value, flagName string) error { + if value == "" { + return nil + } + if err := validate.ResourceName(value, flagName); err != nil { + return output.ErrValidation("%s", err) + } + return nil +} + +func parseWikiNodeRecord(node map[string]interface{}) (*wikiNodeRecord, error) { + if node == nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node response missing node") + } + return &wikiNodeRecord{ + SpaceID: common.GetString(node, "space_id"), + NodeToken: common.GetString(node, "node_token"), + ObjToken: common.GetString(node, "obj_token"), + ObjType: common.GetString(node, "obj_type"), + ParentNodeToken: common.GetString(node, "parent_node_token"), + NodeType: common.GetString(node, "node_type"), + OriginNodeToken: common.GetString(node, "origin_node_token"), + Title: common.GetString(node, "title"), + HasChild: common.GetBool(node, "has_child"), + }, nil +} + +func parseWikiSpaceRecord(space map[string]interface{}) (*wikiSpaceRecord, error) { + if space == nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "wiki space response missing space") + } + return &wikiSpaceRecord{ + SpaceID: common.GetString(space, "space_id"), + Name: common.GetString(space, "name"), + SpaceType: common.GetString(space, "space_type"), + Visibility: common.GetString(space, "visibility"), + OpenSharing: common.GetString(space, "open_sharing"), + }, nil +} + +func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interface{} { + node := execution.Node + return map[string]interface{}{ + "resolved_space_id": execution.ResolvedSpace.SpaceID, + "resolved_by": execution.ResolvedSpace.ResolvedBy, + "space_id": node.SpaceID, + "node_token": node.NodeToken, + "obj_token": node.ObjToken, + "obj_type": node.ObjType, + "node_type": node.NodeType, + "title": node.Title, + "parent_node_token": node.ParentNodeToken, + "origin_node_token": node.OriginNodeToken, + "has_child": node.HasChild, + } +} diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go new file mode 100644 index 00000000..c86b17b5 --- /dev/null +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -0,0 +1,483 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +type fakeWikiNodeCreateCall struct { + SpaceID string + Spec wikiNodeCreateSpec +} + +type fakeWikiNodeCreateClient struct { + spaces map[string]*wikiSpaceRecord + nodes map[string]*wikiNodeRecord + createNode *wikiNodeRecord + createErr error + getSpaceErr error + getNodeErr error + createInvoked []fakeWikiNodeCreateCall +} + +func (fake *fakeWikiNodeCreateClient) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) { + if fake.getNodeErr != nil { + return nil, fake.getNodeErr + } + node, ok := fake.nodes[token] + if !ok { + return &wikiNodeRecord{}, nil + } + return node, nil +} + +func (fake *fakeWikiNodeCreateClient) GetSpace(ctx context.Context, spaceID string) (*wikiSpaceRecord, error) { + if fake.getSpaceErr != nil { + return nil, fake.getSpaceErr + } + space, ok := fake.spaces[spaceID] + if !ok { + return &wikiSpaceRecord{}, nil + } + return space, nil +} + +func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID string, spec wikiNodeCreateSpec) (*wikiNodeRecord, error) { + fake.createInvoked = append(fake.createInvoked, fakeWikiNodeCreateCall{ + SpaceID: spaceID, + Spec: spec, + }) + if fake.createErr != nil { + return nil, fake.createErr + } + if fake.createNode != nil { + return fake.createNode, nil + } + return &wikiNodeRecord{SpaceID: spaceID, Title: spec.Title, NodeType: spec.NodeType, ObjType: spec.ObjType}, nil +} + +var wikiTestConfigSeq atomic.Int64 + +func wikiTestConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: fmt.Sprintf("wiki-test-app-%d", wikiTestConfigSeq.Add(1)), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } +} + +func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "wiki"} + shortcut.Mount(parent, factory) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func TestWikiShortcutsIncludesNodeCreate(t *testing.T) { + t.Parallel() + + shortcuts := Shortcuts() + if len(shortcuts) != 1 { + t.Fatalf("len(Shortcuts()) = %d, want 1", len(shortcuts)) + } + if shortcuts[0].Command != "+node-create" { + t.Fatalf("shortcut command = %q, want %q", shortcuts[0].Command, "+node-create") + } +} + +func TestValidateWikiNodeCreateSpecRejectsShortcutWithoutOriginNodeToken(t *testing.T) { + t.Parallel() + + err := validateWikiNodeCreateSpec(wikiNodeCreateSpec{ + NodeType: wikiNodeTypeShortcut, + ObjType: "docx", + }, core.AsUser) + if err == nil || !strings.Contains(err.Error(), "--origin-node-token is required") { + t.Fatalf("expected shortcut origin-token error, got %v", err) + } +} + +func TestValidateWikiNodeCreateSpecRejectsOriginTokenForOriginNode(t *testing.T) { + t.Parallel() + + err := validateWikiNodeCreateSpec(wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + OriginNodeToken: "wik_origin", + }, core.AsUser) + if err == nil || !strings.Contains(err.Error(), "can only be used when --node-type=shortcut") { + t.Fatalf("expected origin-node-token validation error, got %v", err) + } +} + +func TestValidateWikiNodeCreateSpecRejectsBotWithoutLocation(t *testing.T) { + t.Parallel() + + err := validateWikiNodeCreateSpec(wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + }, core.AsBot) + if err == nil || !strings.Contains(err.Error(), "bot identity requires --space-id or --parent-node-token") { + t.Fatalf("expected bot location validation error, got %v", err) + } +} + +func TestValidateWikiNodeCreateSpecRejectsBotMyLibrarySpaceID(t *testing.T) { + t.Parallel() + + err := validateWikiNodeCreateSpec(wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + SpaceID: wikiMyLibrarySpaceID, + ParentNodeToken: "wik_parent", + }, core.AsBot) + if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") { + t.Fatalf("expected bot my_library validation error, got %v", err) + } +} + +func TestResolveWikiNodeCreateSpaceUsesParentNode(t *testing.T) { + t.Parallel() + + client := &fakeWikiNodeCreateClient{ + nodes: map[string]*wikiNodeRecord{ + "wik_parent": {SpaceID: "space_parent"}, + }, + } + + resolved, err := resolveWikiNodeCreateSpace(context.Background(), client, core.AsUser, wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + ParentNodeToken: "wik_parent", + }) + if err != nil { + t.Fatalf("resolveWikiNodeCreateSpace() error = %v", err) + } + if resolved.SpaceID != "space_parent" { + t.Fatalf("resolved space_id = %q, want %q", resolved.SpaceID, "space_parent") + } + if resolved.ResolvedBy != wikiResolvedByParentNode { + t.Fatalf("resolved_by = %q, want %q", resolved.ResolvedBy, wikiResolvedByParentNode) + } +} + +func TestResolveWikiNodeCreateSpaceRejectsSpaceMismatch(t *testing.T) { + t.Parallel() + + client := &fakeWikiNodeCreateClient{ + nodes: map[string]*wikiNodeRecord{ + "wik_parent": {SpaceID: "space_parent"}, + }, + } + + _, err := resolveWikiNodeCreateSpace(context.Background(), client, core.AsUser, wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + SpaceID: "space_other", + ParentNodeToken: "wik_parent", + }) + if err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("expected mismatch error, got %v", err) + } +} + +func TestResolveWikiNodeCreateSpaceUsesMyLibraryFallback(t *testing.T) { + t.Parallel() + + client := &fakeWikiNodeCreateClient{ + spaces: map[string]*wikiSpaceRecord{ + wikiMyLibrarySpaceID: {SpaceID: "space_my_library", SpaceType: "my_library"}, + }, + } + + resolved, err := resolveWikiNodeCreateSpace(context.Background(), client, core.AsUser, wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + }) + if err != nil { + t.Fatalf("resolveWikiNodeCreateSpace() error = %v", err) + } + if resolved.SpaceID != "space_my_library" { + t.Fatalf("resolved space_id = %q, want %q", resolved.SpaceID, "space_my_library") + } + if resolved.ResolvedBy != wikiResolvedByMyLibrary { + t.Fatalf("resolved_by = %q, want %q", resolved.ResolvedBy, wikiResolvedByMyLibrary) + } +} + +func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) { + t.Parallel() + + client := &fakeWikiNodeCreateClient{ + spaces: map[string]*wikiSpaceRecord{ + wikiMyLibrarySpaceID: {SpaceID: "space_my_library"}, + }, + createNode: &wikiNodeRecord{ + SpaceID: "space_my_library", + NodeToken: "wik_created", + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + Title: "Roadmap", + }, + } + + spec := wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + Title: "Roadmap", + } + execution, err := runWikiNodeCreate(context.Background(), client, core.AsUser, spec) + if err != nil { + t.Fatalf("runWikiNodeCreate() error = %v", err) + } + if len(client.createInvoked) != 1 { + t.Fatalf("create invoked %d times, want 1", len(client.createInvoked)) + } + if client.createInvoked[0].SpaceID != "space_my_library" { + t.Fatalf("create space_id = %q, want %q", client.createInvoked[0].SpaceID, "space_my_library") + } + if execution.Node.NodeToken != "wik_created" { + t.Fatalf("created node token = %q, want %q", execution.Node.NodeToken, "wik_created") + } + if execution.ResolvedSpace.ResolvedBy != wikiResolvedByMyLibrary { + t.Fatalf("resolved_by = %q, want %q", execution.ResolvedSpace.ResolvedBy, wikiResolvedByMyLibrary) + } +} + +func TestWikiNodeCreateDryRunShowsMyLibraryLookup(t *testing.T) { + t.Parallel() + + got := wikiNodeCreateDryRunAPIsForTest(t, func(cmd *cobra.Command) { + if err := cmd.Flags().Set("title", "My Node"); err != nil { + t.Fatalf("set --title: %v", err) + } + }) + + if len(got) != 2 { + t.Fatalf("len(dryRun.api) = %d, want 2", len(got)) + } + if got[0].URL != "/open-apis/wiki/v2/spaces/my_library" { + t.Fatalf("first dry-run URL = %q, want my_library lookup", got[0].URL) + } + if got[1].URL != "/open-apis/wiki/v2/spaces//nodes" { + t.Fatalf("second dry-run URL = %q, want placeholder create URL", got[1].URL) + } + if got[1].Body["title"] != "My Node" { + t.Fatalf("dry-run create body = %#v", got[1].Body) + } +} + +func TestWikiNodeCreateDryRunUsesParentNodeWithoutMyLibraryLookup(t *testing.T) { + t.Parallel() + + got := wikiNodeCreateDryRunAPIsForTest(t, func(cmd *cobra.Command) { + if err := cmd.Flags().Set("title", "Child Node"); err != nil { + t.Fatalf("set --title: %v", err) + } + if err := cmd.Flags().Set("parent-node-token", "wik_parent"); err != nil { + t.Fatalf("set --parent-node-token: %v", err) + } + }) + + if len(got) != 2 { + t.Fatalf("len(dryRun.api) = %d, want 2", len(got)) + } + if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" { + t.Fatalf("first dry-run URL = %q, want parent node lookup", got[0].URL) + } + if got[1].URL != "/open-apis/wiki/v2/spaces//nodes" { + t.Fatalf("second dry-run URL = %q, want placeholder create URL", got[1].URL) + } +} + +func TestWikiNodeCreateDryRunKeepsExplicitSpaceIDWhenParentProvided(t *testing.T) { + t.Parallel() + + got := wikiNodeCreateDryRunAPIsForTest(t, func(cmd *cobra.Command) { + if err := cmd.Flags().Set("title", "Child Node"); err != nil { + t.Fatalf("set --title: %v", err) + } + if err := cmd.Flags().Set("space-id", "space_123"); err != nil { + t.Fatalf("set --space-id: %v", err) + } + if err := cmd.Flags().Set("parent-node-token", "wik_parent"); err != nil { + t.Fatalf("set --parent-node-token: %v", err) + } + }) + + if len(got) != 2 { + t.Fatalf("len(dryRun.api) = %d, want 2", len(got)) + } + if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" { + t.Fatalf("first dry-run URL = %q, want parent node lookup", got[0].URL) + } + if got[1].URL != "/open-apis/wiki/v2/spaces/space_123/nodes" { + t.Fatalf("second dry-run URL = %q, want explicit space create URL", got[1].URL) + } +} + +func TestWikiNodeCreateDryRunShowsMyLibraryLookupWhenExplicitAndParentProvided(t *testing.T) { + t.Parallel() + + got := wikiNodeCreateDryRunAPIsForTest(t, func(cmd *cobra.Command) { + if err := cmd.Flags().Set("title", "Child Node"); err != nil { + t.Fatalf("set --title: %v", err) + } + if err := cmd.Flags().Set("space-id", wikiMyLibrarySpaceID); err != nil { + t.Fatalf("set --space-id: %v", err) + } + if err := cmd.Flags().Set("parent-node-token", "wik_parent"); err != nil { + t.Fatalf("set --parent-node-token: %v", err) + } + }) + + if len(got) != 3 { + t.Fatalf("len(dryRun.api) = %d, want 3", len(got)) + } + if got[0].URL != "/open-apis/wiki/v2/spaces/my_library" { + t.Fatalf("first dry-run URL = %q, want my_library lookup", got[0].URL) + } + if got[1].URL != "/open-apis/wiki/v2/spaces/get_node" { + t.Fatalf("second dry-run URL = %q, want parent node lookup", got[1].URL) + } + if got[2].URL != "/open-apis/wiki/v2/spaces//nodes" { + t.Fatalf("third dry-run URL = %q, want placeholder create URL", got[2].URL) + } +} + +func wikiNodeCreateDryRunAPIsForTest(t *testing.T, setFlags func(*cobra.Command)) []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` +} { + t.Helper() + + cmd := &cobra.Command{Use: "wiki +node-create"} + cmd.Flags().String("space-id", "", "") + cmd.Flags().String("parent-node-token", "", "") + cmd.Flags().String("title", "", "") + cmd.Flags().String("node-type", wikiNodeTypeOrigin, "") + cmd.Flags().String("obj-type", "docx", "") + cmd.Flags().String("origin-node-token", "", "") + if setFlags != nil { + setFlags(cmd) + } + + runtime := common.TestNewRuntimeContext(cmd, nil) + dry := WikiNodeCreate.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + + return got.API +} + +func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_created", + "obj_token": "docx_created", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "origin_node_token": "", + "title": "Wiki Node", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(createStub) + + err := mountAndRunWiki(t, WikiNodeCreate, []string{ + "+node-create", + "--space-id", "space_123", + "--title", "Wiki Node", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if !envelope.OK { + t.Fatalf("expected ok=true, got stdout=%s", stdout.String()) + } + if envelope.Data["resolved_by"] != wikiResolvedByExplicitSpaceID { + t.Fatalf("resolved_by = %#v, want %q", envelope.Data["resolved_by"], wikiResolvedByExplicitSpaceID) + } + if envelope.Data["node_token"] != "wik_created" { + t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created") + } + + var captured map[string]interface{} + if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured request body: %v", err) + } + if captured["node_type"] != wikiNodeTypeOrigin { + t.Fatalf("captured node_type = %#v, want %q", captured["node_type"], wikiNodeTypeOrigin) + } + if captured["obj_type"] != "docx" { + t.Fatalf("captured obj_type = %#v, want %q", captured["obj_type"], "docx") + } + if captured["title"] != "Wiki Node" { + t.Fatalf("captured title = %#v, want %q", captured["title"], "Wiki Node") + } + if got := stderr.String(); !strings.Contains(got, "Created wiki node in space space_123 via explicit_space_id.") { + t.Fatalf("stderr = %q, want completed creation message", got) + } +} diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 918f9c41..40e62757 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -12,6 +12,14 @@ metadata: **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** +## Shortcuts(推荐优先使用) + +Shortcut 是对常用操作的高级封装(`lark-cli wiki + [flags]`)。有 Shortcut 的操作优先使用。 + +| Shortcut | 说明 | +|----------|------| +| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution | + ## API Resources ```bash diff --git a/skills/lark-wiki/references/lark-wiki-node-create.md b/skills/lark-wiki/references/lark-wiki-node-create.md new file mode 100644 index 00000000..6fd00443 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-create.md @@ -0,0 +1,101 @@ +# wiki +node-create + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +在飞书知识库中创建一个新节点,并自动解析目标知识空间。该 shortcut 对原生 `wiki.nodes.create` 做了一层更适合日常使用的封装:可以直接指定 `space_id`,也可以从父节点自动反查所属空间;在 `user` 身份下,如果同时省略 `--space-id` 和 `--parent-node-token`,还会自动回退到个人知识库 `my_library`。 + +## 命令 + +```bash +# 在个人知识库根目录下创建一个 docx 节点(user 身份默认回退到 my_library) +lark-cli wiki +node-create \ + --title "项目计划" + +# 在指定知识空间中创建一个 docx 节点 +lark-cli wiki +node-create \ + --space-id \ + --title "项目计划" + +# 在指定父节点下创建一个子节点 +lark-cli wiki +node-create \ + --parent-node-token \ + --title "迭代记录" + +# 显式指定创建到个人知识库(仅 user 身份;bot 不支持 `--space-id my_library`) +lark-cli wiki +node-create \ + --space-id my_library \ + --title "学习笔记" + +# 创建一个快捷方式节点(shortcut) +lark-cli wiki +node-create \ + --parent-node-token \ + --node-type shortcut \ + --origin-node-token \ + --title "原文档快捷方式" + +# 创建非 docx 类型节点 +lark-cli wiki +node-create \ + --space-id \ + --obj-type sheet \ + --title "周报数据" + +# 预览底层调用链 +lark-cli wiki +node-create \ + --title "Roadmap" \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--space-id` | 否 | 目标知识空间 ID;`user` 身份可传特殊值 `my_library` 表示个人知识库,`bot` 身份不支持该值 | +| `--parent-node-token` | 否 | 父知识库节点 token;传入后会在该节点下创建新节点 | +| `--title` | 否 | 节点标题 | +| `--node-type` | 否 | 节点类型,默认 `origin`;可选值:`origin`、`shortcut` | +| `--obj-type` | 否 | 节点对应对象类型,默认 `docx`;可选值:`sheet`、`mindnote`、`bitable`、`docx`、`slides` | +| `--origin-node-token` | 否 | 当 `--node-type=shortcut` 时必填,表示快捷方式指向的源节点 token | + +## 空间解析规则 + +- **优先级**:`--space-id` > `--parent-node-token` > `my_library` +- **显式 space**:传了 `--space-id` 时,shortcut 会直接使用该空间;如果该值是 `my_library`,则仅 `user` 身份可用,并会先调用 `GET /open-apis/wiki/v2/spaces/my_library` 解析成真实 `space_id` +- **父节点推断**:未传 `--space-id` 但传了 `--parent-node-token` 时,会先调用 `GET /open-apis/wiki/v2/spaces/get_node` 获取父节点,再读取其 `space_id` +- **个人知识库回退**:`user` 身份下,如果 `--space-id` 和 `--parent-node-token` 都没传,会自动解析 `my_library` +- **bot 身份限制**:`bot` 身份既没有“个人知识库”回退语义,也不支持显式传 `--space-id my_library`;请改用真实 `space_id` 或 `--parent-node-token` + +## shortcut 节点规则 + +- `--node-type=shortcut` 时,必须同时提供 `--origin-node-token` +- `--node-type=origin` 时,不能传 `--origin-node-token` +- `shortcut` 节点只是知识库中的快捷方式入口;真正被引用的节点由 `--origin-node-token` 指定 + +## 一致性校验 + +- 如果同时传了 `--space-id` 和 `--parent-node-token`,shortcut 会校验父节点所属空间是否与 `--space-id` 一致 +- 如果两者解析出的空间不一致,命令会直接返回验证错误,而不会继续创建 +- 对于 `my_library`,`user` 身份下也会先解析出真实 `space_id` 后再做这层校验 + +## 行为说明 + +- **默认对象类型**:不传 `--obj-type` 时默认创建 `docx` 节点 +- **默认节点类型**:不传 `--node-type` 时默认创建普通节点 `origin` +- **dry-run 编排**: + - 仅传 `--title`:会展示 `my_library` 解析 + 创建节点 两步调用 + - 仅传 `--parent-node-token`:会展示“查询父节点 -> 创建节点”两步调用 + - 同时需要 `my_library` 和父节点时:会展示三步调用链 +- **输出结果**:成功后会返回 `resolved_space_id`、`resolved_by`、`node_token`、`obj_token`、`obj_type`、`node_type`、`title` 等字段,便于后续继续操作 + +## 推荐场景 + +- 用户说“在我的知识库里新建一篇页面”时,优先用 `lark-cli wiki +node-create --title "..."` +- 用户已经给出父页面链接或 `parent_node_token` 时,优先传 `--parent-node-token`,让 shortcut 自动推导空间 +- 需要创建知识库快捷方式时,使用 `--node-type shortcut --origin-node-token ` + +> [!CAUTION] +> `wiki +node-create` 是**写入操作**,执行前必须确认用户意图。 + +## 参考 + +- [lark-wiki](../SKILL.md) -- 知识库全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数