From e423ab891ccb57e86cff950b2fc70668f3593845 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 10 Apr 2026 16:28:33 +0800 Subject: [PATCH 1/6] feat(wiki): add +space-list, +node-list, +node-copy shortcuts Implement three new wiki shortcuts for organizing and migrating wiki content: - `+space-list`: list all accessible wiki spaces with auto-pagination - `+node-list`: list nodes under a space or parent node with auto-pagination - `+node-copy`: copy a wiki node (and subtree) to a target space or parent node Also includes: - Reference docs under skills/lark-wiki/references/ (with _EXAMPLE_TOKEN placeholders) - Updated skills/lark-wiki/SKILL.md with new shortcuts table entries - 9 unit tests covering registration, pagination, validation, and copy scenarios - scripts/check-doc-tokens.sh: pre-push check that catches realistic-looking example tokens in reference docs and prompts use of _EXAMPLE_TOKEN placeholders - Makefile: add `make gitleaks` target (runs check-doc-tokens then gitleaks) --- .gitleaks.toml | 1 + Makefile | 12 +- scripts/check-doc-tokens.sh | 59 +++ shortcuts/wiki/shortcuts.go | 3 + shortcuts/wiki/wiki_list_copy_test.go | 359 ++++++++++++++++++ shortcuts/wiki/wiki_node_copy.go | 108 ++++++ shortcuts/wiki/wiki_node_create_test.go | 4 +- shortcuts/wiki/wiki_node_list.go | 106 ++++++ shortcuts/wiki/wiki_space_list.go | 70 ++++ skills/lark-wiki/SKILL.md | 3 + .../references/lark-wiki-node-copy.md | 67 ++++ .../references/lark-wiki-node-list.md | 53 +++ .../references/lark-wiki-space-list.md | 43 +++ 13 files changed, 885 insertions(+), 3 deletions(-) create mode 100755 scripts/check-doc-tokens.sh create mode 100644 shortcuts/wiki/wiki_list_copy_test.go create mode 100644 shortcuts/wiki/wiki_node_copy.go create mode 100644 shortcuts/wiki/wiki_node_list.go create mode 100644 shortcuts/wiki/wiki_space_list.go create mode 100644 skills/lark-wiki/references/lark-wiki-node-copy.md create mode 100644 skills/lark-wiki/references/lark-wiki-node-list.md create mode 100644 skills/lark-wiki/references/lark-wiki-space-list.md diff --git a/.gitleaks.toml b/.gitleaks.toml index 597b33952..8dbe4165f 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -14,3 +14,4 @@ id = "lark-session-token" description = "Detect Lark session tokens" regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b''' keywords = ["XN0YXJ0-", "-WVuZA"] + diff --git a/Makefile b/Makefile index 7d78c510b..40dbd4c80 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ DATE := $(shell date +%Y-%m-%d) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local -.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta +.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks fetch_meta: python3 scripts/fetch_meta.py @@ -37,3 +37,13 @@ uninstall: clean: rm -f $(BINARY) + +# Run secret-leak checks locally before pushing. +# Step 1: check-doc-tokens catches realistic-looking example tokens in reference +# docs and asks you to use _EXAMPLE_TOKEN placeholders instead. +# Step 2: gitleaks scans the full repo for real leaked secrets. +# Install gitleaks: https://github.com/gitleaks/gitleaks#installing +gitleaks: + @bash scripts/check-doc-tokens.sh + @command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; } + gitleaks detect --redact -v --exit-code=2 diff --git a/scripts/check-doc-tokens.sh b/scripts/check-doc-tokens.sh new file mode 100755 index 000000000..63264cae7 --- /dev/null +++ b/scripts/check-doc-tokens.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +# +# check-doc-tokens.sh +# +# Scans skill reference docs for token-like values that look realistic but +# are not using the required placeholder format (*_EXAMPLE_TOKEN or similar). +# +# Real token patterns (Lark API) often look like: +# wikcnXXXXXXXXX doccnXXXXXXX shtcnXXX fldcnXXX ou_XXXX cli_XXXX +# +# Docs MUST use clearly fake placeholders, e.g.: +# wikcn_EXAMPLE_TOKEN doccn_EXAMPLE_TOKEN your_token_here +# +# If this check fails, replace the realistic-looking value with a placeholder +# like `wikcn_EXAMPLE_TOKEN` so gitleaks CI won't flag it as a real secret. + +set -euo pipefail + +SKILLS_DIR="${1:-skills}" +ERRORS=0 + +# Patterns that indicate a realistic-looking Lark token value inside a string. +# Matches JSON-style: "field": "token_value" or markdown backtick spans. +# Token prefixes used by Lark Open Platform: +# wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec +# +# Excluded (clearly fake): +# - Values ending with EXAMPLE_TOKEN (e.g. wikcn_EXAMPLE_TOKEN) +# - Values that are all uppercase X (e.g. bascnXXXXXXXX) +# - Values containing only X/_/<> (e.g. ) +REALISTIC_TOKEN_RE='"(wikcn|doccn|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec)[A-Za-z0-9]{6,}"' +PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)' + +while IFS= read -r -d '' file; do + # grep returns exit 1 when no match — use || true to avoid set -e killing us + # Then filter out values that are clearly placeholders (EXAMPLE, XXXX, etc.) + matches=$(grep -nEo "$REALISTIC_TOKEN_RE" "$file" 2>/dev/null | grep -vE "$PLACEHOLDER_RE" || true) + if [[ -n "$matches" ]]; then + echo "" + echo "❌ $file" + echo " Contains realistic-looking token values that may trigger gitleaks:" + while IFS= read -r line; do + echo " $line" + done <<< "$matches" + echo " → Replace with a placeholder, e.g.: wikcn_EXAMPLE_TOKEN, doccn_EXAMPLE_TOKEN" + ERRORS=$((ERRORS + 1)) + fi +done < <(find "$SKILLS_DIR" -path "*/references/*.md" -print0) + +if [[ $ERRORS -gt 0 ]]; then + echo "" + echo "❌ check-doc-tokens: $ERRORS file(s) contain realistic token values in reference docs." + echo " Use _EXAMPLE_TOKEN placeholders to avoid false positives in gitleaks CI." + exit 1 +else + echo "✅ check-doc-tokens: all reference docs use safe placeholder tokens." +fi diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go index f22be7807..da5b388d0 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -11,5 +11,8 @@ func Shortcuts() []common.Shortcut { WikiMove, WikiNodeCreate, WikiDeleteSpace, + WikiSpaceList, + WikiNodeList, + WikiNodeCopy, } } diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go new file mode 100644 index 000000000..483bf6fcd --- /dev/null +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -0,0 +1,359 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +// ── +space-list ────────────────────────────────────────────────────────────── + +func TestWikiShortcutsIncludesSpaceListNodeListNodeCopy(t *testing.T) { + t.Parallel() + + commands := map[string]bool{} + for _, s := range Shortcuts() { + commands[s.Command] = true + } + for _, want := range []string{"+space-list", "+node-list", "+node-copy"} { + if !commands[want] { + t.Errorf("Shortcuts() missing %q", want) + } + } +} + +func TestWikiSpaceListReturnsPaginatedSpaces(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_1", + "name": "Engineering Wiki", + "space_type": "team", + }, + map[string]interface{}{ + "space_id": "space_2", + "name": "Personal Library", + "space_type": "my_library", + }, + }, + }, + "msg": "success", + }, + }) + + err := mountAndRunWiki(t, WikiSpaceList, []string{"+space-list", "--as", "bot"}, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Spaces []map[string]interface{} `json:"spaces"` + Total float64 `json:"total"` + } `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 %s", stdout.String()) + } + if envelope.Data.Total != 2 { + t.Fatalf("total = %v, want 2", envelope.Data.Total) + } + if envelope.Data.Spaces[0]["name"] != "Engineering Wiki" { + t.Fatalf("spaces[0].name = %v, want %q", envelope.Data.Spaces[0]["name"], "Engineering Wiki") + } +} + +// ── +node-list ─────────────────────────────────────────────────────────────── + +func TestWikiNodeListRequiresSpaceID(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeList, []string{"+node-list", "--as", "user"}, factory, nil) + if err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("expected required flag error, got %v", err) + } +} + +func TestWikiNodeListReturnsNodesForSpace(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_node_1", + "obj_token": "docx_1", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Getting Started", + "has_child": true, + }, + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_node_2", + "obj_token": "docx_2", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Architecture", + "has_child": false, + }, + }, + }, + "msg": "success", + }, + }) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "space_123", "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + Total float64 `json:"total"` + } `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 %s", stdout.String()) + } + if envelope.Data.Total != 2 { + t.Fatalf("total = %v, want 2", envelope.Data.Total) + } + if envelope.Data.Nodes[0]["title"] != "Getting Started" { + t.Fatalf("nodes[0].title = %v, want %q", envelope.Data.Nodes[0]["title"], "Getting Started") + } + if envelope.Data.Nodes[0]["has_child"] != true { + t.Fatalf("nodes[0].has_child = %v, want true", envelope.Data.Nodes[0]["has_child"]) + } +} + +func TestWikiNodeListPassesParentNodeToken(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_child", + "obj_token": "docx_child", + "obj_type": "docx", + "parent_node_token": "wik_parent", + "node_type": "origin", + "title": "Child Doc", + "has_child": false, + }, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "space_123", "--parent-node-token", "wik_parent", "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + // Verify the correct node was returned (parent_node_token was passed correctly). + var envelope struct { + OK bool `json:"ok"` + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + } `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 %s", stdout.String()) + } + if len(envelope.Data.Nodes) != 1 { + t.Fatalf("len(nodes) = %d, want 1", len(envelope.Data.Nodes)) + } + if envelope.Data.Nodes[0]["parent_node_token"] != "wik_parent" { + t.Fatalf("nodes[0].parent_node_token = %v, want %q", envelope.Data.Nodes[0]["parent_node_token"], "wik_parent") + } +} + +// ── +node-copy ─────────────────────────────────────────────────────────────── + +func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", "--space-id", "space_123", "--node-token", "wik_src", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") { + t.Fatalf("expected target validation error, got %v", err) + } +} + +func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_copied", + "obj_token": "docx_copied", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Architecture (Copy)", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-space-id", "space_dst", + "--title", "Architecture (Copy)", + "--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 %s", stdout.String()) + } + if envelope.Data["node_token"] != "wik_copied" { + t.Fatalf("node_token = %v, want %q", envelope.Data["node_token"], "wik_copied") + } + if envelope.Data["space_id"] != "space_dst" { + t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst") + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured body: %v", err) + } + if captured["target_space_id"] != "space_dst" { + t.Fatalf("captured target_space_id = %v, want %q", captured["target_space_id"], "space_dst") + } + if captured["title"] != "Architecture (Copy)" { + t.Fatalf("captured title = %v, want %q", captured["title"], "Architecture (Copy)") + } + if got := stderr.String(); !strings.Contains(got, "Copying wiki node") { + t.Fatalf("stderr = %q, want copy message", got) + } +} + +func TestWikiNodeCopyCopiesNodeToTargetParent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_src", + "node_token": "wik_copied2", + "obj_token": "docx_copied2", + "obj_type": "docx", + "parent_node_token": "wik_parent_dst", + "node_type": "origin", + "title": "Architecture", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-parent-node-token", "wik_parent_dst", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured body: %v", err) + } + if captured["target_parent_token"] != "wik_parent_dst" { + t.Fatalf("captured target_parent_token = %v, want %q", captured["target_parent_token"], "wik_parent_dst") + } + if _, hasTitle := captured["title"]; hasTitle { + t.Fatalf("title should not be in body when --title not provided, got %v", captured) + } +} diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go new file mode 100644 index 000000000..c29fa8ecd --- /dev/null +++ b/shortcuts/wiki/wiki_node_copy.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// WikiNodeCopy copies a wiki node into a target space or under a target parent node. +var WikiNodeCopy = common.Shortcut{ + Service: "wiki", + Command: "+node-copy", + Description: "Copy a wiki node to a target space or parent node", + Risk: "write", + Scopes: []string{"wiki:node:copy"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "space-id", Desc: "source wiki space ID", Required: true}, + {Name: "node-token", Desc: "source node token to copy", Required: true}, + {Name: "target-space-id", Desc: "target wiki space ID; required if --target-parent-node-token is not set"}, + {Name: "target-parent-node-token", Desc: "target parent node token; required if --target-space-id is not set"}, + {Name: "title", Desc: "new title for the copied node; leave empty to keep the original title"}, + }, + Tips: []string{ + "At least one of --target-space-id or --target-parent-node-token must be provided.", + "Omit --title to keep the original node title in the copy.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id")) + targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token")) + if targetSpaceID == "" && targetParent == "" { + return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required") + } + if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil { + return err + } + return validateOptionalResourceName(targetParent, "--target-parent-node-token") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + nodeToken := strings.TrimSpace(runtime.Str("node-token")) + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", spaceID, nodeToken)). + Body(buildNodeCopyBody(runtime)). + Set("space_id", spaceID). + Set("node_token", nodeToken) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + nodeToken := strings.TrimSpace(runtime.Str("node-token")) + + fmt.Fprintf(runtime.IO().ErrOut, "Copying wiki node %s from space %s\n", + common.MaskToken(nodeToken), common.MaskToken(spaceID)) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken)), + nil, buildNodeCopyBody(runtime)) + if err != nil { + return err + } + + node, err := parseWikiNodeRecord(common.GetMap(data, "node")) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Copied to node %s in space %s\n", + common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID)) + runtime.Out(wikiNodeCopyOutput(node), nil) + return nil + }, +} + +func buildNodeCopyBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + if v := strings.TrimSpace(runtime.Str("target-space-id")); v != "" { + body["target_space_id"] = v + } + if v := strings.TrimSpace(runtime.Str("target-parent-node-token")); v != "" { + body["target_parent_token"] = v + } + if v := strings.TrimSpace(runtime.Str("title")); v != "" { + body["title"] = v + } + return body +} + +func wikiNodeCopyOutput(node *wikiNodeRecord) map[string]interface{} { + return map[string]interface{}{ + "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, + "has_child": node.HasChild, + } +} diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index e20f07f9e..1dcfc331b 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -111,8 +111,8 @@ func TestWikiShortcutsIncludeAllCommands(t *testing.T) { t.Parallel() shortcuts := Shortcuts() - if len(shortcuts) != 3 { - t.Fatalf("len(Shortcuts()) = %d, want 3", len(shortcuts)) + if len(shortcuts) != 6 { + t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts)) } if shortcuts[0].Command != "+move" { t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move") diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go new file mode 100644 index 000000000..f7a6329ab --- /dev/null +++ b/shortcuts/wiki/wiki_node_list.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// WikiNodeList lists child nodes in a wiki space or under a parent node. +var WikiNodeList = common.Shortcut{ + Service: "wiki", + Command: "+node-list", + Description: "List wiki nodes in a space or under a parent node", + Risk: "read", + Scopes: []string{"wiki:node:retrieve"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "space-id", Desc: "wiki space ID; use my_library for personal document library", Required: true}, + {Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"}, + }, + Tips: []string{ + "Use --parent-node-token to drill into a sub-directory.", + "--space-id my_library lists the root of your personal document library.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil { + return err + } + return validateOptionalResourceName(strings.TrimSpace(runtime.Str("parent-node-token")), "--parent-node-token") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + params := map[string]interface{}{"page_size": 50} + if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" { + params["parent_node_token"] = pt + } + return common.NewDryRunAPI(). + GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", spaceID)). + Params(params). + Set("space_id", spaceID) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + if spaceID == "" { + return output.ErrValidation("--space-id is required") + } + parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token")) + + var nodes []map[string]interface{} + pageToken := "" + for { + params := map[string]interface{}{"page_size": 50} + if parentNodeToken != "" { + params["parent_node_token"] = parentNodeToken + } + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := runtime.CallAPI("GET", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID)), + params, nil) + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + nodes = append(nodes, wikiNodeListItem(m)) + } + } + next, _ := data["page_token"].(string) + hasMore, _ := data["has_more"].(bool) + if !hasMore || next == "" { + break + } + pageToken = next + } + fmt.Fprintf(runtime.IO().ErrOut, "Found %d node(s)\n", len(nodes)) + runtime.Out(map[string]interface{}{ + "nodes": nodes, + "total": len(nodes), + }, nil) + return nil + }, +} + +func wikiNodeListItem(m map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "space_id": common.GetString(m, "space_id"), + "node_token": common.GetString(m, "node_token"), + "obj_token": common.GetString(m, "obj_token"), + "obj_type": common.GetString(m, "obj_type"), + "parent_node_token": common.GetString(m, "parent_node_token"), + "node_type": common.GetString(m, "node_type"), + "title": common.GetString(m, "title"), + "has_child": common.GetBool(m, "has_child"), + } +} diff --git a/shortcuts/wiki/wiki_space_list.go b/shortcuts/wiki/wiki_space_list.go new file mode 100644 index 000000000..e94a57c8a --- /dev/null +++ b/shortcuts/wiki/wiki_space_list.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + + "github.com/larksuite/cli/shortcuts/common" +) + +// WikiSpaceList lists all wiki spaces the caller has access to. +var WikiSpaceList = common.Shortcut{ + Service: "wiki", + Command: "+space-list", + Description: "List wiki spaces accessible to the caller", + Risk: "read", + Scopes: []string{"wiki:space:retrieve"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{}, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/wiki/v2/spaces"). + Params(map[string]interface{}{"page_size": 50}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + var spaces []map[string]interface{} + pageToken := "" + for { + params := map[string]interface{}{"page_size": 50} + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := runtime.CallAPI("GET", "/open-apis/wiki/v2/spaces", params, nil) + if err != nil { + return err + } + items, _ := data["items"].([]interface{}) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + spaces = append(spaces, parseWikiSpaceItem(m)) + } + } + next, _ := data["page_token"].(string) + hasMore, _ := data["has_more"].(bool) + if !hasMore || next == "" { + break + } + pageToken = next + } + fmt.Fprintf(runtime.IO().ErrOut, "Found %d wiki space(s)\n", len(spaces)) + runtime.Out(map[string]interface{}{ + "spaces": spaces, + "total": len(spaces), + }, nil) + return nil + }, +} + +func parseWikiSpaceItem(m map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "space_id": common.GetString(m, "space_id"), + "name": common.GetString(m, "name"), + "description": common.GetString(m, "description"), + "space_type": common.GetString(m, "space_type"), + "visibility": common.GetString(m, "visibility"), + "open_sharing": common.GetString(m, "open_sharing"), + } +} diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 964344349..73b241083 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -57,6 +57,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki + [flags]`) | [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki | | [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution | | [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed | +| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller | +| [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) | +| [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node | ## API Resources diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md new file mode 100644 index 000000000..e71c72e54 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -0,0 +1,67 @@ +# lark-wiki +node-copy + +Copy a wiki node (including its content) to a target space or under a target parent node. Used for cross-space migration. + +## Usage + +```bash +lark-cli wiki +node-copy \ + --space-id \ + --node-token \ + --target-space-id \ + [--target-parent-node-token ] \ + [--title ] \ + [--as user|bot] +``` + +## Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--space-id` | **Yes** | Source wiki space ID | +| `--node-token` | **Yes** | Source node token to copy | +| `--target-space-id` | Conditional | Target space ID. Required if `--target-parent-node-token` is not set | +| `--target-parent-node-token` | Conditional | Target parent node token. Required if `--target-space-id` is not set | +| `--title` | No | New title for the copied node. Omit to keep the original title | +| `--as` | No | Identity: `user` or `bot` (default: `user`) | + +> At least one of `--target-space-id` or `--target-parent-node-token` must be provided. + +## Output + +```json +{ + "space_id": "target_space_id", + "node_token": "wikcn_EXAMPLE_TOKEN", + "obj_token": "doccn_EXAMPLE_TOKEN", + "obj_type": "docx", + "node_type": "origin", + "title": "Getting Started (Copy)", + "parent_node_token": "", + "has_child": false +} +``` + +## Migration workflow + +To migrate a subtree from one space to another: + +```bash +# 1. List nodes in the source space +lark-cli wiki +node-list --space-id source_space_id + +# 2. Copy each node to the target space +lark-cli wiki +node-copy \ + --space-id source_space_id \ + --node-token wikcnSource \ + --target-space-id target_space_id +``` + +## Notes + +- Copying is recursive — the subtree under the node is also copied. +- There is no native move API; migration = copy to target + (manually delete source if needed). + +## Required Scope + +`wiki:node:copy` diff --git a/skills/lark-wiki/references/lark-wiki-node-list.md b/skills/lark-wiki/references/lark-wiki-node-list.md new file mode 100644 index 000000000..1968a5f2f --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-list.md @@ -0,0 +1,53 @@ +# lark-wiki +node-list + +List wiki nodes in a space or under a specific parent node. Automatically paginates through all pages. + +## Usage + +```bash +lark-cli wiki +node-list --space-id [--parent-node-token ] [--as user|bot] +``` + +## Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--space-id` | **Yes** | Wiki space ID. Use `my_library` for personal document library | +| `--parent-node-token` | No | Parent node token. Omit to list root-level nodes of the space | +| `--as` | No | Identity: `user` or `bot` (default: `user`) | + +## Output + +```json +{ + "nodes": [ + { + "space_id": "6946843325487912356", + "node_token": "wikcn_EXAMPLE_TOKEN", + "obj_token": "doccn_EXAMPLE_TOKEN", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Getting Started", + "has_child": true + } + ], + "total": 1 +} +``` + +## Traverse the wiki tree + +To list all content recursively, call `+node-list` again with each node's `node_token` as `--parent-node-token` when `has_child` is `true`. + +```bash +# Step 1: list root nodes +lark-cli wiki +node-list --space-id 6946843325487912356 + +# Step 2: drill into a node that has children +lark-cli wiki +node-list --space-id 6946843325487912356 --parent-node-token wikcn_EXAMPLE_TOKEN +``` + +## Required Scope + +`wiki:node:retrieve` diff --git a/skills/lark-wiki/references/lark-wiki-space-list.md b/skills/lark-wiki/references/lark-wiki-space-list.md new file mode 100644 index 000000000..ab868e820 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-space-list.md @@ -0,0 +1,43 @@ +# lark-wiki +space-list + +List all wiki spaces accessible to the caller. Automatically paginates through all pages. + +## Usage + +```bash +lark-cli wiki +space-list [--as user|bot] +``` + +## Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--as` | No | Identity: `user` or `bot` (default: `user`) | + +## Output + +```json +{ + "spaces": [ + { + "space_id": "6946843325487912356", + "name": "Engineering Wiki", + "description": "...", + "space_type": "team", + "visibility": "private", + "open_sharing": "closed" + } + ], + "total": 1 +} +``` + +## Notes + +- Returns all spaces in a single call via auto-pagination. +- Use `space_id` from the output as `--space-id` for `+node-list` or `+node-copy`. +- `my_library` is the personal document library; its `space_id` is returned as a numeric ID in results. + +## Required Scope + +`wiki:space:retrieve` From 84629b413379fdb5dbc0bcae96521497e87ba193 Mon Sep 17 00:00:00 2001 From: baiqing Date: Fri, 10 Apr 2026 16:42:09 +0800 Subject: [PATCH 2/6] fix(wiki): address coderabbit review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wiki_node_list.go: use EncodePathSegment in DryRun URL; remove dead `if spaceID == ""` guard (cobra enforces Required: true before Execute) - wiki_node_copy.go: use EncodePathSegment in DryRun URL; add Validate checks for --space-id and --node-token (not just target flags) - wiki_list_copy_test.go: assert parent_node_token is forwarded in the outgoing request URL (not just in the response body) - check-doc-tokens.sh: extend regex to cover docx*, ou_, cli_ prefixes and backtick-quoted spans - lark-wiki-node-copy.md: clarify usage — target flags are mutual-exclusive (one of --target-space-id | --target-parent-node-token) - lark-wiki-space-list.md: fix misleading "single call" wording - Makefile: add `all` phony target --- Makefile | 4 +++- scripts/check-doc-tokens.sh | 2 +- shortcuts/wiki/wiki_list_copy_test.go | 2 +- shortcuts/wiki/wiki_node_copy.go | 10 +++++++++- shortcuts/wiki/wiki_node_list.go | 6 +----- skills/lark-wiki/references/lark-wiki-node-copy.md | 3 +-- skills/lark-wiki/references/lark-wiki-space-list.md | 2 +- 7 files changed, 17 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 40dbd4c80..7733335b4 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,9 @@ DATE := $(shell date +%Y-%m-%d) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local -.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks +.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks + +all: test fetch_meta: python3 scripts/fetch_meta.py diff --git a/scripts/check-doc-tokens.sh b/scripts/check-doc-tokens.sh index 63264cae7..571efacf2 100755 --- a/scripts/check-doc-tokens.sh +++ b/scripts/check-doc-tokens.sh @@ -30,7 +30,7 @@ ERRORS=0 # - Values ending with EXAMPLE_TOKEN (e.g. wikcn_EXAMPLE_TOKEN) # - Values that are all uppercase X (e.g. bascnXXXXXXXX) # - Values containing only X/_/<> (e.g. ) -REALISTIC_TOKEN_RE='"(wikcn|doccn|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec)[A-Za-z0-9]{6,}"' +REALISTIC_TOKEN_RE='"(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]{6,}"|`(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]{6,}`' PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)' while IFS= read -r -d '' file; do diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index 483bf6fcd..9dd57dd45 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -172,7 +172,7 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) { stub := &httpmock.Stub{ Method: "GET", - URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes?page_size=50&parent_node_token=wik_parent", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index c29fa8ecd..b195a7c5e 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -33,6 +33,12 @@ var WikiNodeCopy = common.Shortcut{ "Omit --title to keep the original node title in the copy.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("space-id")), "--space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("node-token")), "--node-token"); err != nil { + return err + } targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id")) targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token")) if targetSpaceID == "" && targetParent == "" { @@ -47,7 +53,9 @@ var WikiNodeCopy = common.Shortcut{ spaceID := strings.TrimSpace(runtime.Str("space-id")) nodeToken := strings.TrimSpace(runtime.Str("node-token")) return common.NewDryRunAPI(). - POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", spaceID, nodeToken)). + POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/copy", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken))). Body(buildNodeCopyBody(runtime)). Set("space_id", spaceID). Set("node_token", nodeToken) diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go index f7a6329ab..6e5864f09 100644 --- a/shortcuts/wiki/wiki_node_list.go +++ b/shortcuts/wiki/wiki_node_list.go @@ -8,7 +8,6 @@ import ( "fmt" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -43,15 +42,12 @@ var WikiNodeList = common.Shortcut{ params["parent_node_token"] = pt } return common.NewDryRunAPI(). - GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", spaceID)). + GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))). Params(params). Set("space_id", spaceID) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spaceID := strings.TrimSpace(runtime.Str("space-id")) - if spaceID == "" { - return output.ErrValidation("--space-id is required") - } parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token")) var nodes []map[string]interface{} diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md index e71c72e54..bd7cce87a 100644 --- a/skills/lark-wiki/references/lark-wiki-node-copy.md +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -8,8 +8,7 @@ Copy a wiki node (including its content) to a target space or under a target par lark-cli wiki +node-copy \ --space-id \ --node-token \ - --target-space-id \ - [--target-parent-node-token ] \ + (--target-space-id | --target-parent-node-token ) \ [--title ] \ [--as user|bot] ``` diff --git a/skills/lark-wiki/references/lark-wiki-space-list.md b/skills/lark-wiki/references/lark-wiki-space-list.md index ab868e820..1561247b1 100644 --- a/skills/lark-wiki/references/lark-wiki-space-list.md +++ b/skills/lark-wiki/references/lark-wiki-space-list.md @@ -34,7 +34,7 @@ lark-cli wiki +space-list [--as user|bot] ## Notes -- Returns all spaces in a single call via auto-pagination. +- Returns all spaces via automatic pagination; the command may issue multiple API requests under the hood. - Use `space_id` from the output as `--space-id` for `+node-list` or `+node-copy`. - `my_library` is the personal document library; its `space_id` is returned as a numeric ID in results. From f18688b58a053ab04ff3c593e08f985fc8f06a7b Mon Sep 17 00:00:00 2001 From: baiqing Date: Sat, 11 Apr 2026 22:01:49 +0800 Subject: [PATCH 3/6] feat(wiki): add +node-move shortcut Implements wiki node move using the native POST .../nodes/{token}/move API. Unlike +node-copy, the node is removed from its original location. - Flags: --space-id, --node-token, --target-space-id | --target-parent-node-token - Scope: wiki:node:move - 3 unit tests covering: target validation, move to space, move to parent - Reference doc: skills/lark-wiki/references/lark-wiki-node-move.md Also: - check-doc-tokens.sh: require at least one digit in token suffix to avoid false positives on plain-word fake names (e.g. ou_manager, ou_director) - Fix pre-existing realistic example tokens in lark-minutes and lark-doc reference docs (found by the updated script) --- scripts/check-doc-tokens.sh | 4 +- shortcuts/wiki/shortcuts.go | 1 + shortcuts/wiki/wiki_list_copy_test.go | 128 ++++++++++++++++++ shortcuts/wiki/wiki_node_create_test.go | 4 +- shortcuts/wiki/wiki_node_move.go | 113 ++++++++++++++++ skills/lark-doc/references/lark-doc-search.md | 10 +- skills/lark-wiki/SKILL.md | 2 + .../references/lark-wiki-node-move.md | 59 ++++++++ 8 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 shortcuts/wiki/wiki_node_move.go create mode 100644 skills/lark-wiki/references/lark-wiki-node-move.md diff --git a/scripts/check-doc-tokens.sh b/scripts/check-doc-tokens.sh index 571efacf2..aaea21ac0 100755 --- a/scripts/check-doc-tokens.sh +++ b/scripts/check-doc-tokens.sh @@ -30,7 +30,9 @@ ERRORS=0 # - Values ending with EXAMPLE_TOKEN (e.g. wikcn_EXAMPLE_TOKEN) # - Values that are all uppercase X (e.g. bascnXXXXXXXX) # - Values containing only X/_/<> (e.g. ) -REALISTIC_TOKEN_RE='"(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]{6,}"|`(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]{6,}`' +# Require at least one digit in the suffix — real API tokens are always alphanumeric +# with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names. +REALISTIC_TOKEN_RE='"(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}"|`(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}`' PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)' while IFS= read -r -d '' file; do diff --git a/shortcuts/wiki/shortcuts.go b/shortcuts/wiki/shortcuts.go index da5b388d0..abec61140 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -14,5 +14,6 @@ func Shortcuts() []common.Shortcut { WikiSpaceList, WikiNodeList, WikiNodeCopy, + WikiNodeMove, } } diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index 9dd57dd45..3b01f3a29 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -357,3 +357,131 @@ func TestWikiNodeCopyCopiesNodeToTargetParent(t *testing.T) { t.Fatalf("title should not be in body when --title not provided, got %v", captured) } } + +// ── +node-move ─────────────────────────────────────────────────────────────── + +func TestWikiNodeMoveRequiresTargetSpaceOrParent(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeMove, []string{ + "+node-move", "--space-id", "space_123", "--node-token", "wik_src", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") { + t.Fatalf("expected target validation error, got %v", err) + } +} + +func TestWikiNodeMoveMovesNodeToTargetSpace(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_dst", + "node_token": "wik_src", + "obj_token": "docx_src", + "obj_type": "docx", + "parent_node_token": "", + "node_type": "origin", + "title": "Architecture", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeMove, []string{ + "+node-move", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-space-id", "space_dst", + "--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 %s", stdout.String()) + } + if envelope.Data["space_id"] != "space_dst" { + t.Fatalf("space_id = %v, want %q", envelope.Data["space_id"], "space_dst") + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured body: %v", err) + } + if captured["target_space_id"] != "space_dst" { + t.Fatalf("captured target_space_id = %v, want %q", captured["target_space_id"], "space_dst") + } + if got := stderr.String(); !strings.Contains(got, "Moving wiki node") { + t.Fatalf("stderr = %q, want move message", got) + } +} + +func TestWikiNodeMoveMovesNodeToTargetParent(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_src/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_src", + "node_token": "wik_src", + "obj_token": "docx_src", + "obj_type": "docx", + "parent_node_token": "wik_parent_dst", + "node_type": "origin", + "title": "Architecture", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(stub) + + err := mountAndRunWiki(t, WikiNodeMove, []string{ + "+node-move", + "--space-id", "space_src", + "--node-token", "wik_src", + "--target-parent-node-token", "wik_parent_dst", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var captured map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil { + t.Fatalf("unmarshal captured body: %v", err) + } + if captured["target_parent_token"] != "wik_parent_dst" { + t.Fatalf("captured target_parent_token = %v, want %q", captured["target_parent_token"], "wik_parent_dst") + } + if _, hasSpaceID := captured["target_space_id"]; hasSpaceID { + t.Fatalf("target_space_id should not be in body when not provided, got %v", captured) + } +} diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 1dcfc331b..0dab4bdab 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -111,8 +111,8 @@ func TestWikiShortcutsIncludeAllCommands(t *testing.T) { t.Parallel() shortcuts := Shortcuts() - if len(shortcuts) != 6 { - t.Fatalf("len(Shortcuts()) = %d, want 6", len(shortcuts)) + if len(shortcuts) != 7 { + t.Fatalf("len(Shortcuts()) = %d, want 7", len(shortcuts)) } if shortcuts[0].Command != "+move" { t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move") diff --git a/shortcuts/wiki/wiki_node_move.go b/shortcuts/wiki/wiki_node_move.go new file mode 100644 index 000000000..e2036a04f --- /dev/null +++ b/shortcuts/wiki/wiki_node_move.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// WikiNodeMove moves a wiki node to a target space or under a target parent node. +var WikiNodeMove = common.Shortcut{ + Service: "wiki", + Command: "+node-move", + Description: "Move a wiki node to a target space or parent node", + Risk: "write", + Scopes: []string{"wiki:node:move"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "space-id", Desc: "source wiki space ID", Required: true}, + {Name: "node-token", Desc: "node token to move", Required: true}, + {Name: "target-space-id", Desc: "target wiki space ID; required if --target-parent-node-token is not set"}, + {Name: "target-parent-node-token", Desc: "target parent node token; required if --target-space-id is not set"}, + }, + Tips: []string{ + "At least one of --target-space-id or --target-parent-node-token must be provided.", + "Moving is recursive — the entire subtree under the node is moved together.", + "Unlike copy, move removes the node from its original location.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("space-id")), "--space-id"); err != nil { + return err + } + if err := validateOptionalResourceName(strings.TrimSpace(runtime.Str("node-token")), "--node-token"); err != nil { + return err + } + targetSpaceID := strings.TrimSpace(runtime.Str("target-space-id")) + targetParent := strings.TrimSpace(runtime.Str("target-parent-node-token")) + if targetSpaceID == "" && targetParent == "" { + return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required") + } + if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil { + return err + } + return validateOptionalResourceName(targetParent, "--target-parent-node-token") + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + nodeToken := strings.TrimSpace(runtime.Str("node-token")) + return common.NewDryRunAPI(). + POST(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/move", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken))). + Body(buildNodeMoveBody(runtime)). + Set("space_id", spaceID). + Set("node_token", nodeToken) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spaceID := strings.TrimSpace(runtime.Str("space-id")) + nodeToken := strings.TrimSpace(runtime.Str("node-token")) + + fmt.Fprintf(runtime.IO().ErrOut, "Moving wiki node %s from space %s\n", + common.MaskToken(nodeToken), common.MaskToken(spaceID)) + + data, err := runtime.CallAPI("POST", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes/%s/move", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(nodeToken)), + nil, buildNodeMoveBody(runtime)) + if err != nil { + return err + } + + node, err := parseWikiNodeRecord(common.GetMap(data, "node")) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Moved to node %s in space %s\n", + common.MaskToken(node.NodeToken), common.MaskToken(node.SpaceID)) + runtime.Out(wikiNodeMoveOutput(node), nil) + return nil + }, +} + +func buildNodeMoveBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + if v := strings.TrimSpace(runtime.Str("target-space-id")); v != "" { + body["target_space_id"] = v + } + if v := strings.TrimSpace(runtime.Str("target-parent-node-token")); v != "" { + body["target_parent_token"] = v + } + return body +} + +func wikiNodeMoveOutput(node *wikiNodeRecord) map[string]interface{} { + return map[string]interface{}{ + "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, + "has_child": node.HasChild, + } +} diff --git a/skills/lark-doc/references/lark-doc-search.md b/skills/lark-doc/references/lark-doc-search.md index 6ca0df4d4..3047a6c2d 100644 --- a/skills/lark-doc/references/lark-doc-search.md +++ b/skills/lark-doc/references/lark-doc-search.md @@ -57,7 +57,7 @@ lark-cli docs +search \ # 按文档所有者过滤(creator_ids 传文档所有者 open_id,不是邮箱 / user_id) lark-cli docs +search \ --query "季度总结" \ - --filter '{"creator_ids":["ou_7890123456abcdef"]}' + --filter '{"creator_ids":["ou_EXAMPLE_USER_ID"]}' # 只搜索指定类型 lark-cli docs +search \ @@ -87,7 +87,7 @@ lark-cli docs +search \ # 只搜索指定分享者分享过的文档(sharer_ids 传分享者 open_id,最多 20 个) lark-cli docs +search \ --query "复盘" \ - --filter '{"sharer_ids":["ou_7890123456abcdef"]}' + --filter '{"sharer_ids":["ou_EXAMPLE_USER_ID"]}' # 按创建时间过滤并指定排序方式 lark-cli docs +search \ @@ -97,7 +97,7 @@ lark-cli docs +search \ # 组合多个筛选条件 lark-cli docs +search \ --query "项目复盘" \ - --filter '{"creator_ids":["ou_7890123456abcdef"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}' + --filter '{"creator_ids":["ou_EXAMPLE_USER_ID"],"doc_types":["DOCX","SHEET"],"only_title":true,"sort_type":"OPEN_TIME","open_time":{"start":"2026-01-01T00:00:00+08:00"}}' # 只在指定知识空间下搜 Wiki lark-cli docs +search \ @@ -179,10 +179,10 @@ lark-cli docs +search --query "方案" --format json --page-token '' ### 常见 `--filter` JSON 片段 ```json -{"creator_ids":["ou_7890123456abcdef"]} +{"creator_ids":["ou_EXAMPLE_USER_ID"]} {"doc_types":["SHEET","DOCX"]} {"chat_ids":["oc_1234567890abcdef"]} -{"sharer_ids":["ou_7890123456abcdef"]} +{"sharer_ids":["ou_EXAMPLE_USER_ID"]} {"folder_tokens":["fld_123456"]} {"only_title":true} {"only_comment":true} diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 73b241083..61a28d1a6 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -60,6 +60,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki + [flags]`) | [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller | | [`+node-list`](references/lark-wiki-node-list.md) | List wiki nodes in a space or under a parent node (supports pagination) | | [`+node-copy`](references/lark-wiki-node-copy.md) | Copy a wiki node to a target space or parent node | +| [`+node-move`](references/lark-wiki-node-move.md) | Move a wiki node (and subtree) to a target space or parent node | ## API Resources @@ -101,6 +102,7 @@ lark-cli wiki [flags] # 调用 API | `members.delete` | `wiki:member:update` | | `members.list` | `wiki:member:retrieve` | | `nodes.copy` | `wiki:node:copy` | +| `nodes.move` | `wiki:node:move` | | `nodes.create` | `wiki:node:create` | | `nodes.list` | `wiki:node:retrieve` | diff --git a/skills/lark-wiki/references/lark-wiki-node-move.md b/skills/lark-wiki/references/lark-wiki-node-move.md new file mode 100644 index 000000000..f98fa3212 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-move.md @@ -0,0 +1,59 @@ +# lark-wiki +node-move + +Move a wiki node (and its subtree) to a target space or under a target parent node. Unlike copy, the node is removed from its original location. + +## Usage + +```bash +lark-cli wiki +node-move \ + --space-id \ + --node-token \ + (--target-space-id | --target-parent-node-token ) \ + [--as user|bot] +``` + +## Flags + +| Flag | Required | Description | +|------|----------|-------------| +| `--space-id` | **Yes** | Source wiki space ID | +| `--node-token` | **Yes** | Token of the node to move | +| `--target-space-id` | Conditional | Target space ID. Required if `--target-parent-node-token` is not set | +| `--target-parent-node-token` | Conditional | Target parent node token. Required if `--target-space-id` is not set | +| `--as` | No | Identity: `user` or `bot` (default: `user`) | + +> At least one of `--target-space-id` or `--target-parent-node-token` must be provided. + +## Output + +```json +{ + "space_id": "target_space_id", + "node_token": "wikcn_EXAMPLE_TOKEN", + "obj_token": "doccn_EXAMPLE_TOKEN", + "obj_type": "docx", + "node_type": "origin", + "title": "Getting Started", + "parent_node_token": "wikcn_EXAMPLE_TOKEN", + "has_child": false +} +``` + +## Difference from +node-copy + +| | `+node-move` | `+node-copy` | +|--|--|--| +| Original node | Removed | Kept | +| `--title` flag | Not supported | Optional | +| Use case | Reorganize structure | Duplicate / migrate | + +## Notes + +- Moving is recursive — the entire subtree is moved together. +- Requires editing permission on the node, its original parent, and the target parent. +- Rate limit: 100 calls/minute. +- Single move operation (including child nodes) cannot exceed 2,000 nodes. + +## Required Scope + +`wiki:node:move` From ffe5239a7b1fa05d7a5e6f34f9a51ad4c9d664ca Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 14 Apr 2026 11:47:28 +0800 Subject: [PATCH 4/6] chore: add .tmp/ to .gitignore and untrack temporary files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 90313e480..dc576a2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ cmd/api/download.bin app.log /sidecar-server-demo /server-demo +.tmp/ From 86812ff916655dd776f84a1966b969a38c7a6020 Mon Sep 17 00:00:00 2001 From: baiqing Date: Tue, 14 Apr 2026 14:23:14 +0800 Subject: [PATCH 5/6] fix(wiki): address fangshuyu-768 review comments - wiki_node_copy.go: reject mutually exclusive --target-space-id and --target-parent-node-token; add test TestWikiNodeCopyRejectsBothTargetFlags - wiki_node_list.go: remove misleading my_library tip (not resolved by this shortcut); point users to +space-list instead - lark-wiki-node-copy.md: replace wikcnSource with wikcn_EXAMPLE_TOKEN in migration workflow example --- shortcuts/wiki/wiki_list_copy_test.go | 14 ++++++++++++++ shortcuts/wiki/wiki_node_copy.go | 3 +++ shortcuts/wiki/wiki_node_list.go | 4 ++-- skills/lark-wiki/references/lark-wiki-node-copy.md | 6 +++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index 3b01f3a29..b2b2a1ed0 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -237,6 +237,20 @@ func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) { } } +func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeCopy, []string{ + "+node-copy", "--space-id", "space_123", "--node-token", "wik_src", + "--target-space-id", "space_dst", "--target-parent-node-token", "wik_parent", + "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutually exclusive error, got %v", err) + } +} + func TestWikiNodeCopyCopiesNodeToTargetSpace(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) diff --git a/shortcuts/wiki/wiki_node_copy.go b/shortcuts/wiki/wiki_node_copy.go index b195a7c5e..00b3e87ad 100644 --- a/shortcuts/wiki/wiki_node_copy.go +++ b/shortcuts/wiki/wiki_node_copy.go @@ -44,6 +44,9 @@ var WikiNodeCopy = common.Shortcut{ if targetSpaceID == "" && targetParent == "" { return output.ErrValidation("at least one of --target-space-id or --target-parent-node-token is required") } + if targetSpaceID != "" && targetParent != "" { + return output.ErrValidation("--target-space-id and --target-parent-node-token are mutually exclusive; provide only one") + } if err := validateOptionalResourceName(targetSpaceID, "--target-space-id"); err != nil { return err } diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go index 6e5864f09..b0873a9ac 100644 --- a/shortcuts/wiki/wiki_node_list.go +++ b/shortcuts/wiki/wiki_node_list.go @@ -21,12 +21,12 @@ var WikiNodeList = common.Shortcut{ Scopes: []string{"wiki:node:retrieve"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "space-id", Desc: "wiki space ID; use my_library for personal document library", Required: true}, + {Name: "space-id", Desc: "wiki space ID; use +space-list to discover available space IDs", Required: true}, {Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"}, }, Tips: []string{ "Use --parent-node-token to drill into a sub-directory.", - "--space-id my_library lists the root of your personal document library.", + "Run +space-list first to discover your space IDs, including the personal document library.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { spaceID := strings.TrimSpace(runtime.Str("space-id")) diff --git a/skills/lark-wiki/references/lark-wiki-node-copy.md b/skills/lark-wiki/references/lark-wiki-node-copy.md index bd7cce87a..e55391b6e 100644 --- a/skills/lark-wiki/references/lark-wiki-node-copy.md +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -51,9 +51,9 @@ lark-cli wiki +node-list --space-id source_space_id # 2. Copy each node to the target space lark-cli wiki +node-copy \ - --space-id source_space_id \ - --node-token wikcnSource \ - --target-space-id target_space_id + --space-id \ + --node-token wikcn_EXAMPLE_TOKEN \ + --target-space-id ``` ## Notes From ccb6dbd113324303785bdfc45f2560d00c1b696b Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 20 Apr 2026 11:15:44 +0800 Subject: [PATCH 6/6] fix(wiki): handle my_library alias in +node-list; harden doc-token checker - wiki_node_list.go: reject --space-id my_library for bot identity, and auto-resolve the alias to the per-user real space_id before listing, mirroring the orchestration used by +node-create. Add two tests (TestWikiNodeListRejectsMyLibraryForBot / ResolvesMyLibraryForUser). - wiki_node_create.go: extract resolveMyLibrarySpaceID helper so the resolution is shared across wiki shortcuts. - check-doc-tokens.sh: broaden detection to also catch bare token values (e.g. inside fenced code blocks), not only quoted/backticked forms. - lark-minutes-search.md: replace the realistic bare obcn token with obcn_EXAMPLE_TOKEN so the stricter checker passes. --- scripts/check-doc-tokens.sh | 19 +++-- shortcuts/wiki/wiki_list_copy_test.go | 75 +++++++++++++++++++ shortcuts/wiki/wiki_node_create.go | 19 +++++ shortcuts/wiki/wiki_node_list.go | 38 +++++++++- .../references/lark-minutes-search.md | 2 +- 5 files changed, 143 insertions(+), 10 deletions(-) diff --git a/scripts/check-doc-tokens.sh b/scripts/check-doc-tokens.sh index aaea21ac0..a02c8f140 100755 --- a/scripts/check-doc-tokens.sh +++ b/scripts/check-doc-tokens.sh @@ -21,18 +21,23 @@ set -euo pipefail SKILLS_DIR="${1:-skills}" ERRORS=0 -# Patterns that indicate a realistic-looking Lark token value inside a string. -# Matches JSON-style: "field": "token_value" or markdown backtick spans. +# Patterns that indicate a realistic-looking Lark token value. +# Three forms are detected: +# 1. JSON-style quoted strings: "field": "token_value" +# 2. Markdown backtick spans: `token_value` +# 3. Bare tokens: --flag wikcnABC123 (e.g. inside fenced code blocks) +# # Token prefixes used by Lark Open Platform: # wikcn doccn docx shtcn bascn fldcn vewcn tbln ou_ cli_ obcn flec # -# Excluded (clearly fake): -# - Values ending with EXAMPLE_TOKEN (e.g. wikcn_EXAMPLE_TOKEN) -# - Values that are all uppercase X (e.g. bascnXXXXXXXX) -# - Values containing only X/_/<> (e.g. ) +# Excluded (clearly fake, matched by PLACEHOLDER_RE below): +# - Values containing EXAMPLE / _TOKEN / XXXX / your_ / _here +# - Angle-bracket placeholders # Require at least one digit in the suffix — real API tokens are always alphanumeric # with digits. Pure-letter suffixes (e.g. ou_manager, ou_director) are clearly fake names. -REALISTIC_TOKEN_RE='"(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}"|`(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}`' +PREFIXES='(wikcn|doccn|docx[a-z]|shtcn|bascn|fldcn|vewcn|tbln|obcn|flec|ou_|cli_)' +TOKEN_BODY="${PREFIXES}"'[A-Za-z0-9]*[0-9][A-Za-z0-9]{3,}' +REALISTIC_TOKEN_RE="\"${TOKEN_BODY}\"|\`${TOKEN_BODY}\`|\\b${TOKEN_BODY}\\b" PLACEHOLDER_RE='(EXAMPLE|_TOKEN|XXXX|xxxx|<|>|your_|_here)' while IFS= read -r -d '' file; do diff --git a/shortcuts/wiki/wiki_list_copy_test.go b/shortcuts/wiki/wiki_list_copy_test.go index b2b2a1ed0..c5c323ef2 100644 --- a/shortcuts/wiki/wiki_list_copy_test.go +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -223,6 +223,81 @@ func TestWikiNodeListPassesParentNodeToken(t *testing.T) { } } +func TestWikiNodeListRejectsMyLibraryForBot(t *testing.T) { + t.Parallel() + + factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig()) + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "my_library", "--as", "bot", + }, factory, nil) + if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") { + t.Fatalf("expected my_library bot rejection, got %v", err) + } +} + +func TestWikiNodeListResolvesMyLibraryForUser(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig()) + + // Step 1: resolve my_library to the real space_id. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/my_library", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "space": map[string]interface{}{ + "space_id": "space_personal_42", + "name": "My Library", + "space_type": "my_library", + }, + }, + }, + }) + // Step 2: list nodes in the resolved space. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/space_personal_42/nodes", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "space_personal_42", + "node_token": "wik_personal_1", + "title": "Personal Note", + }, + }, + }, + }, + }) + + err := mountAndRunWiki(t, WikiNodeList, []string{ + "+node-list", "--space-id", "my_library", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + Nodes []map[string]interface{} `json:"nodes"` + Total float64 `json:"total"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + if envelope.Data.Total != 1 { + t.Fatalf("total = %v, want 1", envelope.Data.Total) + } + if envelope.Data.Nodes[0]["space_id"] != "space_personal_42" { + t.Fatalf("nodes[0].space_id = %v, want space_personal_42", envelope.Data.Nodes[0]["space_id"]) + } +} + // ── +node-copy ─────────────────────────────────────────────────────────────── func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) { diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index 2c5c67ad4..47e7d4ccd 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -413,6 +413,25 @@ func requireWikiSpaceID(space *wikiSpaceRecord) (string, error) { return "", output.ErrValidation("personal document library was not found, please specify --space-id") } +// resolveMyLibrarySpaceID calls GET /wiki/v2/spaces/my_library and returns +// the per-user real space_id. Shared by shortcuts that accept the my_library +// alias (e.g. +node-create, +node-list) so the behavior stays consistent. +func resolveMyLibrarySpaceID(runtime *common.RuntimeContext) (string, error) { + data, err := runtime.CallAPI( + "GET", + fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(wikiMyLibrarySpaceID)), + nil, nil, + ) + if err != nil { + return "", err + } + space, err := parseWikiSpaceRecord(common.GetMap(data, "space")) + if err != nil { + return "", err + } + return requireWikiSpaceID(space) +} + func validateOptionalResourceName(value, flagName string) error { if value == "" { return nil diff --git a/shortcuts/wiki/wiki_node_list.go b/shortcuts/wiki/wiki_node_list.go index b0873a9ac..c1ce24483 100644 --- a/shortcuts/wiki/wiki_node_list.go +++ b/shortcuts/wiki/wiki_node_list.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -21,15 +22,23 @@ var WikiNodeList = common.Shortcut{ Scopes: []string{"wiki:node:retrieve"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "space-id", Desc: "wiki space ID; use +space-list to discover available space IDs", Required: true}, + {Name: "space-id", Desc: "wiki space ID; use my_library for the personal document library, or +space-list to discover other space IDs", Required: true}, {Name: "parent-node-token", Desc: "parent node token; if omitted, lists the root-level nodes of the space"}, }, Tips: []string{ "Use --parent-node-token to drill into a sub-directory.", "Run +space-list first to discover your space IDs, including the personal document library.", + "--space-id my_library is a per-user alias and is only valid with --as user.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { spaceID := strings.TrimSpace(runtime.Str("space-id")) + // my_library is a per-user personal-library alias; it has no meaning + // for a tenant_access_token (--as bot), so reject early with a clear + // hint instead of deferring to API-time errors. Matches the contract + // used by +node-create and +move. + if runtime.As().IsBot() && spaceID == wikiMyLibrarySpaceID { + return output.ErrValidation("bot identity does not support --space-id my_library; use an explicit --space-id") + } if err := validateOptionalResourceName(spaceID, "--space-id"); err != nil { return err } @@ -41,7 +50,21 @@ var WikiNodeList = common.Shortcut{ if pt := strings.TrimSpace(runtime.Str("parent-node-token")); pt != "" { params["parent_node_token"] = pt } - return common.NewDryRunAPI(). + d := common.NewDryRunAPI() + // When the caller passes my_library, +node-list must first resolve it + // to the real per-user space_id before listing nodes, mirroring the + // two-step orchestration used by +node-create. + if spaceID == wikiMyLibrarySpaceID { + d.Desc("2-step orchestration: resolve my_library -> list nodes"). + GET("/open-apis/wiki/v2/spaces/my_library"). + Desc("[1] Resolve my_library space ID"). + GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", "")). + Desc("[2] List nodes"). + Params(params). + Set("space_id", "") + return d + } + return d. GET(fmt.Sprintf("/open-apis/wiki/v2/spaces/%s/nodes", validate.EncodePathSegment(spaceID))). Params(params). Set("space_id", spaceID) @@ -50,6 +73,17 @@ var WikiNodeList = common.Shortcut{ spaceID := strings.TrimSpace(runtime.Str("space-id")) parentNodeToken := strings.TrimSpace(runtime.Str("parent-node-token")) + // Resolve the my_library alias to the per-user real space_id before + // listing, so the subsequent request hits a concrete space endpoint. + if spaceID == wikiMyLibrarySpaceID { + resolved, err := resolveMyLibrarySpaceID(runtime) + if err != nil { + return err + } + fmt.Fprintf(runtime.IO().ErrOut, "Resolved my_library to space %s\n", common.MaskToken(resolved)) + spaceID = resolved + } + var nodes []map[string]interface{} pageToken := "" for { diff --git a/skills/lark-minutes/references/lark-minutes-search.md b/skills/lark-minutes/references/lark-minutes-search.md index 86b7e5da4..cec6099ee 100644 --- a/skills/lark-minutes/references/lark-minutes-search.md +++ b/skills/lark-minutes/references/lark-minutes-search.md @@ -174,7 +174,7 @@ lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token '