diff --git a/.gitignore b/.gitignore index ff149081..c4c9c7c6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ tests/mail/reports/ internal/registry/meta_data.json cmd/api/download.bin app.log +.tmp/ diff --git a/.gitleaks.toml b/.gitleaks.toml index 597b3395..8dbe4165 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 7d78c510..7733335b 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 +.PHONY: all build vet test unit-test integration-test install uninstall clean fetch_meta gitleaks + +all: test fetch_meta: python3 scripts/fetch_meta.py @@ -37,3 +39,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 00000000..aaea21ac --- /dev/null +++ b/scripts/check-doc-tokens.sh @@ -0,0 +1,61 @@ +#!/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. ) +# 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 + # 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 d670827e..00ef959f 100644 --- a/shortcuts/wiki/shortcuts.go +++ b/shortcuts/wiki/shortcuts.go @@ -10,5 +10,8 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ WikiMove, WikiNodeCreate, + 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 00000000..940fd40d --- /dev/null +++ b/shortcuts/wiki/wiki_list_copy_test.go @@ -0,0 +1,373 @@ +// 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?page_size=50&parent_node_token=wik_parent", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "items": []interface{}{ + map[string]interface{}{ + "space_id": "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 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()) + + 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 00000000..00b3e87a --- /dev/null +++ b/shortcuts/wiki/wiki_node_copy.go @@ -0,0 +1,119 @@ +// 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 { + 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 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 + } + 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", + validate.EncodePathSegment(spaceID), + validate.EncodePathSegment(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 7fd184d6..7a99dfaa 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -98,8 +98,8 @@ func TestWikiShortcutsIncludeMoveAndNodeCreate(t *testing.T) { t.Parallel() shortcuts := Shortcuts() - if len(shortcuts) != 2 { - t.Fatalf("len(Shortcuts()) = %d, want 2", len(shortcuts)) + if len(shortcuts) == 0 { + t.Fatalf("len(Shortcuts()) = 0, want at least 1") } 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 00000000..b0873a9a --- /dev/null +++ b/shortcuts/wiki/wiki_node_list.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package wiki + +import ( + "context" + "fmt" + "strings" + + "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 +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.", + "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")) + 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", 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")) + 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 00000000..e94a57c8 --- /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-doc/references/lark-doc-search.md b/skills/lark-doc/references/lark-doc-search.md index 92affe61..018cb708 100644 --- a/skills/lark-doc/references/lark-doc-search.md +++ b/skills/lark-doc/references/lark-doc-search.md @@ -53,7 +53,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 \ @@ -83,7 +83,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 \ @@ -93,7 +93,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 \ @@ -175,10 +175,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-minutes/references/lark-minutes-download.md b/skills/lark-minutes/references/lark-minutes-download.md index 64402728..01de7beb 100644 --- a/skills/lark-minutes/references/lark-minutes-download.md +++ b/skills/lark-minutes/references/lark-minutes-download.md @@ -11,22 +11,22 @@ ```bash # 下载单个妙记的音视频文件 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c +lark-cli minutes +download --minute-tokens obcn_EXAMPLE_TOKEN # 指定输出路径 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4 +lark-cli minutes +download --minute-tokens obcn_EXAMPLE_TOKEN --output ./meeting.mp4 # 仅获取下载链接(有效期 1 天),不下载文件 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only +lark-cli minutes +download --minute-tokens obcn_EXAMPLE_TOKEN --url-only # 批量下载多个妙记 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj +lark-cli minutes +download --minute-tokens obcn_EXAMPLE_TOKEN,obcn_EXAMPLE_TOKEN2 # 批量下载到指定目录 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj --output ./downloads +lark-cli minutes +download --minute-tokens obcn_EXAMPLE_TOKEN,obcn_EXAMPLE_TOKEN2 --output ./downloads # 预览 API 调用 -lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --dry-run +lark-cli minutes +download --minute-tokens obcn_EXAMPLE_TOKEN --dry-run ``` ## 参数 @@ -91,7 +91,7 @@ API 限流 5 次/秒,批量下载时需注意控制频率。 | 来源 | 获取方式 | |------|---------| -| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c` → `obcnq3b9jl72l83w4f149w9c` | +| 妙记 URL | 从 URL 末尾提取,如 `https://sample.feishu.cn/minutes/obcn_EXAMPLE_TOKEN` → `obcn_EXAMPLE_TOKEN` | | 妙记元信息查询 | `lark-cli minutes minutes get --params '{"minute_token": "obcn..."}'` | | 会议纪要查询 | `lark-cli vc +notes --meeting-ids ` 返回结果中关联的妙记 token | diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 0322e803..61cca476 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -42,6 +42,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 | +| [`+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 | ## 目标语义约束 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 00000000..e55391b6 --- /dev/null +++ b/skills/lark-wiki/references/lark-wiki-node-copy.md @@ -0,0 +1,66 @@ +# 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 \ + --node-token wikcn_EXAMPLE_TOKEN \ + --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 00000000..1968a5f2 --- /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 00000000..1561247b --- /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 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. + +## Required Scope + +`wiki:space:retrieve`